'use strict'; var Lib = require('../../lib'); var autorange = require('./autorange'); var id2name = require('./axis_ids').id2name; var layoutAttributes = require('./layout_attributes'); var scaleZoom = require('./scale_zoom'); var setConvert = require('./set_convert'); var ALMOST_EQUAL = require('../../constants/numerical').ALMOST_EQUAL; var FROM_BL = require('../../constants/alignment').FROM_BL; exports.handleDefaults = function(layoutIn, layoutOut, opts) { var axIds = opts.axIds; var axHasImage = opts.axHasImage; // sets of axes linked by `scaleanchor` OR `matches` along with the // scaleratios compounded together, populated in handleConstraintDefaults var constraintGroups = layoutOut._axisConstraintGroups = []; // similar to _axisConstraintGroups, but only matching axes var matchGroups = layoutOut._axisMatchGroups = []; var i, group, axId, axName, axIn, axOut, attr, val; for(i = 0; i < axIds.length; i++) { axName = id2name(axIds[i]); axIn = layoutIn[axName]; axOut = layoutOut[axName]; handleOneAxDefaults(axIn, axOut, { axIds: axIds, layoutOut: layoutOut, hasImage: axHasImage[axName] }); } // save matchGroup on each matching axis function stash(groups, stashAttr) { for(i = 0; i < groups.length; i++) { group = groups[i]; for(axId in group) { layoutOut[id2name(axId)][stashAttr] = group; } } } stash(matchGroups, '_matchGroup'); // If any axis in a constraint group is fixedrange, they all get fixed // This covers matches axes, as they're now in the constraintgroup too // and have not yet been removed (if the group is *only* matching) for(i = 0; i < constraintGroups.length; i++) { group = constraintGroups[i]; for(axId in group) { axOut = layoutOut[id2name(axId)]; if(axOut.fixedrange) { for(var axId2 in group) { var axName2 = id2name(axId2); if((layoutIn[axName2] || {}).fixedrange === false) { Lib.warn( 'fixedrange was specified as false for axis ' + axName2 + ' but was overridden because another ' + 'axis in its constraint group has fixedrange true' ); } layoutOut[axName2].fixedrange = true; } break; } } } // remove constraint groups that simply duplicate match groups i = 0; while(i < constraintGroups.length) { group = constraintGroups[i]; for(axId in group) { axOut = layoutOut[id2name(axId)]; if(axOut._matchGroup && Object.keys(axOut._matchGroup).length === Object.keys(group).length) { constraintGroups.splice(i, 1); i--; } break; } i++; } // save constraintGroup on each constrained axis stash(constraintGroups, '_constraintGroup'); // make sure `matching` axes share values of necessary attributes // Precedence (base axis is the one that doesn't list a `matches`, ie others // all point to it): // (1) explicitly defined value in the base axis // (2) explicitly defined in another axis (arbitrary order) // (3) default in the base axis var matchAttrs = [ 'constrain', 'range', 'autorange', 'rangemode', 'rangebreaks', 'categoryorder', 'categoryarray' ]; var hasRange = false; var hasDayOfWeekBreaks = false; function setAttrVal() { val = axOut[attr]; if(attr === 'rangebreaks') { hasDayOfWeekBreaks = axOut._hasDayOfWeekBreaks; } } for(i = 0; i < matchGroups.length; i++) { group = matchGroups[i]; // find 'matching' range attrs for(var j = 0; j < matchAttrs.length; j++) { attr = matchAttrs[j]; val = null; var baseAx; for(axId in group) { axName = id2name(axId); axIn = layoutIn[axName]; axOut = layoutOut[axName]; if(!(attr in axOut)) { continue; } if(!axOut.matches) { baseAx = axOut; // top priority: explicit value in base axis if(attr in axIn) { setAttrVal(); break; } } if(val === null && attr in axIn) { // second priority: first explicit value in another axis setAttrVal(); } } // special logic for coupling of range and autorange // if nobody explicitly specifies autorange, but someone does // explicitly specify range, autorange must be disabled. if(attr === 'range' && val && axIn.range && axIn.range.length === 2 && axIn.range[0] !== null && axIn.range[1] !== null ) { hasRange = true; } if(attr === 'autorange' && val === null && hasRange) { val = false; } if(val === null && attr in baseAx) { // fallback: default value in base axis val = baseAx[attr]; } // but we still might not have a value, which is fine. if(val !== null) { for(axId in group) { axOut = layoutOut[id2name(axId)]; axOut[attr] = attr === 'range' ? val.slice() : val; if(attr === 'rangebreaks') { axOut._hasDayOfWeekBreaks = hasDayOfWeekBreaks; setConvert(axOut, layoutOut); } } } } } }; function handleOneAxDefaults(axIn, axOut, opts) { var axIds = opts.axIds; var layoutOut = opts.layoutOut; var hasImage = opts.hasImage; var constraintGroups = layoutOut._axisConstraintGroups; var matchGroups = layoutOut._axisMatchGroups; var axId = axOut._id; var axLetter = axId.charAt(0); var splomStash = ((layoutOut._splomAxes || {})[axLetter] || {})[axId] || {}; var thisID = axOut._id; var isX = thisID.charAt(0) === 'x'; // Clear _matchGroup & _constraintGroup so relinkPrivateKeys doesn't keep // an old one around. If this axis is in a group we'll set this again later axOut._matchGroup = null; axOut._constraintGroup = null; function coerce(attr, dflt) { return Lib.coerce(axIn, axOut, layoutAttributes, attr, dflt); } // coerce the constraint mechanics even if this axis has no scaleanchor // because it may be the anchor of another axis. coerce('constrain', hasImage ? 'domain' : 'range'); Lib.coerce(axIn, axOut, { constraintoward: { valType: 'enumerated', values: isX ? ['left', 'center', 'right'] : ['bottom', 'middle', 'top'], dflt: isX ? 'center' : 'middle' } }, 'constraintoward'); // If this axis is already part of a constraint group, we can't // scaleanchor any other axis in that group, or we'd make a loop. // Filter axIds to enforce this, also matching axis types. var thisType = axOut.type; var i, idi; var linkableAxes = []; for(i = 0; i < axIds.length; i++) { idi = axIds[i]; if(idi === thisID) continue; var axi = layoutOut[id2name(idi)]; if(axi.type === thisType) { linkableAxes.push(idi); } } var thisGroup = getConstraintGroup(constraintGroups, thisID); if(thisGroup) { var linkableAxesNoLoops = []; for(i = 0; i < linkableAxes.length; i++) { idi = linkableAxes[i]; if(!thisGroup[idi]) linkableAxesNoLoops.push(idi); } linkableAxes = linkableAxesNoLoops; } var canLink = linkableAxes.length; var matches, scaleanchor; if(canLink && (axIn.matches || splomStash.matches)) { matches = Lib.coerce(axIn, axOut, { matches: { valType: 'enumerated', values: linkableAxes, dflt: linkableAxes.indexOf(splomStash.matches) !== -1 ? splomStash.matches : undefined } }, 'matches'); } // 'matches' wins over 'scaleanchor' - each axis can only specify one // constraint, but you can chain matches and scaleanchor constraints by // specifying them in separate axes. var scaleanchorDflt = hasImage && !isX ? axOut.anchor : undefined; if(canLink && !matches && (axIn.scaleanchor || scaleanchorDflt)) { scaleanchor = Lib.coerce(axIn, axOut, { scaleanchor: { valType: 'enumerated', values: linkableAxes.concat([false]) } }, 'scaleanchor', scaleanchorDflt); } if(matches) { axOut._matchGroup = updateConstraintGroups(matchGroups, thisID, matches, 1); // Also include match constraints in the scale groups var matchedAx = layoutOut[id2name(matches)]; var matchRatio = extent(layoutOut, axOut) / extent(layoutOut, matchedAx); if(isX !== (matches.charAt(0) === 'x')) { // We don't yet know the actual scale ratio of x/y matches constraints, // due to possible automargins, so just leave a placeholder for this: // 'x' means "x size over y size", 'y' means the inverse. // in principle in the constraint group you could get multiple of these. matchRatio = (isX ? 'x' : 'y') + matchRatio; } updateConstraintGroups(constraintGroups, thisID, matches, matchRatio); } else if(axIn.matches && axIds.indexOf(axIn.matches) !== -1) { Lib.warn('ignored ' + axOut._name + '.matches: "' + axIn.matches + '" to avoid an infinite loop'); } if(scaleanchor) { var scaleratio = coerce('scaleratio'); // TODO: I suppose I could do attribute.min: Number.MIN_VALUE to avoid zero, // but that seems hacky. Better way to say "must be a positive number"? // Of course if you use several super-tiny values you could eventually // force a product of these to zero and all hell would break loose... // Likewise with super-huge values. if(!scaleratio) scaleratio = axOut.scaleratio = 1; updateConstraintGroups(constraintGroups, thisID, scaleanchor, scaleratio); } else if(axIn.scaleanchor && axIds.indexOf(axIn.scaleanchor) !== -1) { Lib.warn('ignored ' + axOut._name + '.scaleanchor: "' + axIn.scaleanchor + '" to avoid either an infinite loop ' + 'and possibly inconsistent scaleratios, or because this axis ' + 'declares a *matches* constraint.'); } } function extent(layoutOut, ax) { var domain = ax.domain; if(!domain) { // at this point overlaying axes haven't yet inherited their main domains // TODO: constrain: domain with overlaying axes is likely a bug. domain = layoutOut[id2name(ax.overlaying)].domain; } return domain[1] - domain[0]; } function getConstraintGroup(groups, thisID) { for(var i = 0; i < groups.length; i++) { if(groups[i][thisID]) { return groups[i]; } } return null; } /* * Add this axis to the axis constraint groups, which is the collection * of axes that are all constrained together on scale (or matching). * * constraintGroups: a list of objects. each object is * {axis_id: scale_within_group}, where scale_within_group is * only important relative to the rest of the group, and defines * the relative scales between all axes in the group * * thisGroup: the group the current axis is already in * thisID: the id if the current axis * thatID: the id of the axis to scale it with * scaleratio: the ratio of this axis to the thatID axis */ function updateConstraintGroups(constraintGroups, thisID, thatID, scaleratio) { var i, j, groupi, keyj, thisGroupIndex; var thisGroup = getConstraintGroup(constraintGroups, thisID); if(thisGroup === null) { thisGroup = {}; thisGroup[thisID] = 1; thisGroupIndex = constraintGroups.length; constraintGroups.push(thisGroup); } else { thisGroupIndex = constraintGroups.indexOf(thisGroup); } var thisGroupKeys = Object.keys(thisGroup); // we know that this axis isn't in any other groups, but we don't know // about the thatID axis. If it is, we need to merge the groups. for(i = 0; i < constraintGroups.length; i++) { groupi = constraintGroups[i]; if(i !== thisGroupIndex && groupi[thatID]) { var baseScale = groupi[thatID]; for(j = 0; j < thisGroupKeys.length; j++) { keyj = thisGroupKeys[j]; groupi[keyj] = multiplyScales(baseScale, multiplyScales(scaleratio, thisGroup[keyj])); } constraintGroups.splice(thisGroupIndex, 1); return; } } // otherwise, we insert the new thatID axis as the base scale (1) // in its group, and scale the rest of the group to it if(scaleratio !== 1) { for(j = 0; j < thisGroupKeys.length; j++) { var key = thisGroupKeys[j]; thisGroup[key] = multiplyScales(scaleratio, thisGroup[key]); } } thisGroup[thatID] = 1; } // scales may be numbers or 'x1.3', 'yy4.5' etc to multiply by as-yet-unknown // ratios between x and y plot sizes n times function multiplyScales(a, b) { var aPrefix = ''; var bPrefix = ''; var aLen, bLen; if(typeof a === 'string') { aPrefix = a.match(/^[xy]*/)[0]; aLen = aPrefix.length; a = +a.substr(aLen); } if(typeof b === 'string') { bPrefix = b.match(/^[xy]*/)[0]; bLen = bPrefix.length; b = +b.substr(bLen); } var c = a * b; // just two numbers if(!aLen && !bLen) { return c; } // one or more prefixes of the same type if(!aLen || !bLen || aPrefix.charAt(0) === bPrefix.charAt(0)) { return aPrefix + bPrefix + (a * b); } // x and y cancel each other out exactly - back to a number if(aLen === bLen) { return c; } // partial cancelation of prefixes return (aLen > bLen ? aPrefix.substr(bLen) : bPrefix.substr(aLen)) + c; } function finalRatios(group, fullLayout) { var size = fullLayout._size; var yRatio = size.h / size.w; var out = {}; var keys = Object.keys(group); for(var i = 0; i < keys.length; i++) { var key = keys[i]; var val = group[key]; if(typeof val === 'string') { var prefix = val.match(/^[xy]*/)[0]; var pLen = prefix.length; val = +val.substr(pLen); var mult = prefix.charAt(0) === 'y' ? yRatio : (1 / yRatio); for(var j = 0; j < pLen; j++) { val *= mult; } } out[key] = val; } return out; } exports.enforce = function enforce(gd) { var fullLayout = gd._fullLayout; var constraintGroups = fullLayout._axisConstraintGroups || []; var i, j, group, axisID, ax, normScale, mode, factor; // matching constraints are handled in the autorange code when autoranged, // or in the supplyDefaults code when explicitly ranged. // now we just need to handle scaleanchor constraints // matches constraints that chain with scaleanchor constraints are included // here too, but because matches has already been satisfied, // any changes here should preserve that. for(i = 0; i < constraintGroups.length; i++) { group = finalRatios(constraintGroups[i], fullLayout); var axisIDs = Object.keys(group); var minScale = Infinity; var maxScale = 0; // mostly matchScale will be the same as minScale // ie we expand axis ranges to encompass *everything* // that's currently in any of their ranges, but during // autorange of a subset of axes we will ignore other // axes for this purpose. var matchScale = Infinity; var normScales = {}; var axes = {}; var hasAnyDomainConstraint = false; // find the (normalized) scale of each axis in the group for(j = 0; j < axisIDs.length; j++) { axisID = axisIDs[j]; axes[axisID] = ax = fullLayout[id2name(axisID)]; if(ax._inputDomain) ax.domain = ax._inputDomain.slice(); else ax._inputDomain = ax.domain.slice(); if(!ax._inputRange) ax._inputRange = ax.range.slice(); // set axis scale here so we can use _m rather than // having to calculate it from length and range ax.setScale(); // abs: inverted scales still satisfy the constraint normScales[axisID] = normScale = Math.abs(ax._m) / group[axisID]; minScale = Math.min(minScale, normScale); if(ax.constrain === 'domain' || !ax._constraintShrinkable) { matchScale = Math.min(matchScale, normScale); } // this has served its purpose, so remove it delete ax._constraintShrinkable; maxScale = Math.max(maxScale, normScale); if(ax.constrain === 'domain') hasAnyDomainConstraint = true; } // Do we have a constraint mismatch? Give a small buffer for rounding errors if(minScale > ALMOST_EQUAL * maxScale && !hasAnyDomainConstraint) continue; // now increase any ranges we need to until all normalized scales are equal for(j = 0; j < axisIDs.length; j++) { axisID = axisIDs[j]; normScale = normScales[axisID]; ax = axes[axisID]; mode = ax.constrain; // even if the scale didn't change, if we're shrinking domain // we need to recalculate in case `constraintoward` changed if(normScale !== matchScale || mode === 'domain') { factor = normScale / matchScale; if(mode === 'range') { scaleZoom(ax, factor); } else { // mode === 'domain' var inputDomain = ax._inputDomain; var domainShrunk = (ax.domain[1] - ax.domain[0]) / (inputDomain[1] - inputDomain[0]); var rangeShrunk = (ax.r2l(ax.range[1]) - ax.r2l(ax.range[0])) / (ax.r2l(ax._inputRange[1]) - ax.r2l(ax._inputRange[0])); factor /= domainShrunk; if(factor * rangeShrunk < 1) { // we've asked to magnify the axis more than we can just by // enlarging the domain - so we need to constrict range ax.domain = ax._input.domain = inputDomain.slice(); scaleZoom(ax, factor); continue; } if(rangeShrunk < 1) { // the range has previously been constricted by ^^, but we've // switched to the domain-constricted regime, so reset range ax.range = ax._input.range = ax._inputRange.slice(); factor *= rangeShrunk; } if(ax.autorange) { /* * range & factor may need to change because range was * calculated for the larger scaling, so some pixel * paddings may get cut off when we reduce the domain. * * This is easier than the regular autorange calculation * because we already know the scaling `m`, but we still * need to cut out impossible constraints (like * annotations with super-long arrows). That's what * outerMin/Max are for - if the expansion was going to * go beyond the original domain, it must be impossible */ var rl0 = ax.r2l(ax.range[0]); var rl1 = ax.r2l(ax.range[1]); var rangeCenter = (rl0 + rl1) / 2; var rangeMin = rangeCenter; var rangeMax = rangeCenter; var halfRange = Math.abs(rl1 - rangeCenter); // extra tiny bit for rounding errors, in case we actually // *are* expanding to the full domain var outerMin = rangeCenter - halfRange * factor * 1.0001; var outerMax = rangeCenter + halfRange * factor * 1.0001; var getPadMin = autorange.makePadFn(fullLayout, ax, 0); var getPadMax = autorange.makePadFn(fullLayout, ax, 1); updateDomain(ax, factor); var m = Math.abs(ax._m); var extremes = autorange.concatExtremes(gd, ax); var minArray = extremes.min; var maxArray = extremes.max; var newVal; var k; for(k = 0; k < minArray.length; k++) { newVal = minArray[k].val - getPadMin(minArray[k]) / m; if(newVal > outerMin && newVal < rangeMin) { rangeMin = newVal; } } for(k = 0; k < maxArray.length; k++) { newVal = maxArray[k].val + getPadMax(maxArray[k]) / m; if(newVal < outerMax && newVal > rangeMax) { rangeMax = newVal; } } var domainExpand = (rangeMax - rangeMin) / (2 * halfRange); factor /= domainExpand; rangeMin = ax.l2r(rangeMin); rangeMax = ax.l2r(rangeMax); ax.range = ax._input.range = (rl0 < rl1) ? [rangeMin, rangeMax] : [rangeMax, rangeMin]; } updateDomain(ax, factor); } } } } }; exports.getAxisGroup = function getAxisGroup(fullLayout, axId) { var matchGroups = fullLayout._axisMatchGroups; for(var i = 0; i < matchGroups.length; i++) { var group = matchGroups[i]; if(group[axId]) return 'g' + i; } return axId; }; // For use before autoranging, check if this axis was previously constrained // by domain but no longer is exports.clean = function clean(gd, ax) { if(ax._inputDomain) { var isConstrained = false; var axId = ax._id; var constraintGroups = gd._fullLayout._axisConstraintGroups; for(var j = 0; j < constraintGroups.length; j++) { if(constraintGroups[j][axId]) { isConstrained = true; break; } } if(!isConstrained || ax.constrain !== 'domain') { ax._input.domain = ax.domain = ax._inputDomain; delete ax._inputDomain; } } }; function updateDomain(ax, factor) { var inputDomain = ax._inputDomain; var centerFraction = FROM_BL[ax.constraintoward]; var center = inputDomain[0] + (inputDomain[1] - inputDomain[0]) * centerFraction; ax.domain = ax._input.domain = [ center + (inputDomain[0] - center) / factor, center + (inputDomain[1] - center) / factor ]; ax.setScale(); }