diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 25b60cfa28e..38fcbd4e60a 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -235,7 +235,7 @@ module.exports = { // '-' means we haven't yet run autotype or couldn't find any data // it gets turned into linear in gd._fullLayout but not copied back // to gd.data like the others are. - values: ['-', 'linear', 'log', 'date', 'category', 'multicategory'], + values: ['-', 'linear', 'log', 'symlog', 'date', 'category', 'multicategory'], dflt: '-', editType: 'calc', // we forget when an axis has been autotyped, just writing the auto @@ -247,7 +247,9 @@ module.exports = { 'Sets the axis type.', 'By default, plotly attempts to determined the axis type', 'by looking into the data of the traces that referenced', - 'the axis in question.' + 'the axis in question.', + 'With *symlog*, the axis is log-scaled with a linear range', + 'around zero.' ].join(' ') }, autotypenumbers: { diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index a51ff198456..f55342b1ba9 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -78,6 +78,16 @@ module.exports = function setConvert(ax, fullLayout) { } else return BADNUM; } + function toSymlog(v) { + // Using asinh as a smooth approximation to symlog + // TODO: Make the linear threshold configurable? Currently implicit 1. + return Math.asinh(v) / Math.LN10; + } + + function fromSymlog(v) { + return Math.sinh(v * Math.LN10); + } + /* * wrapped dateTime2ms that: * - accepts ms numbers for backward compatibility @@ -241,14 +251,22 @@ module.exports = function setConvert(ax, fullLayout) { } // conversions among c/l/p are fairly simple - do them together for all axis types - ax.c2l = (ax.type === 'log') ? toLog : ensureNumber; - ax.l2c = (ax.type === 'log') ? fromLog : ensureNumber; - - ax.l2p = l2p; - ax.p2l = p2l; - - ax.c2p = (ax.type === 'log') ? function(v, clip) { return l2p(toLog(v, clip)); } : l2p; - ax.p2c = (ax.type === 'log') ? function(px) { return fromLog(p2l(px)); } : p2l; + if(ax.type === 'log') { + ax.c2l = toLog; + ax.l2c = fromLog; + ax.c2p = function(v, clip) { return l2p(toLog(v, clip)); }; + ax.p2c = function(px) { return fromLog(p2l(px)); }; + } else if(ax.type === 'symlog') { + ax.c2l = toSymlog; + ax.l2c = fromSymlog; + ax.c2p = function(v) { return l2p(toSymlog(v)); }; + ax.p2c = function(px) { return fromSymlog(p2l(px)); }; + } else { + ax.c2l = ensureNumber; + ax.l2c = ensureNumber; + ax.c2p = l2p; + ax.p2c = p2l; + } /* * now type-specific conversions for **ALL** other combinations @@ -281,6 +299,25 @@ module.exports = function setConvert(ax, fullLayout) { ax.r2p = function(v) { return ax.l2p(cleanNumber(v)); }; ax.p2r = p2l; + ax.cleanPos = ensureNumber; + } else if(ax.type === 'symlog') { + // Symlog implementation using arcsinh + // d and c are data vals, r and l are transformed (symlogged) + ax.d2r = ax.d2l = function(v) { return toSymlog(cleanNumber(v)); }; + ax.r2d = ax.r2c = function(v) { return fromSymlog(cleanNumber(v)); }; + + ax.d2c = ax.r2l = cleanNumber; + ax.c2d = ax.l2r = ensureNumber; + + ax.c2r = toSymlog; + ax.l2d = fromSymlog; + + ax.d2p = function(v) { return ax.l2p(ax.d2r(v)); }; + ax.p2d = function(px) { return fromSymlog(p2l(px)); }; + + ax.r2p = function(v) { return ax.l2p(cleanNumber(v)); }; + ax.p2r = p2l; + ax.cleanPos = ensureNumber; } else if(ax.type === 'date') { // r and d are date strings, l and c are ms diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 5cb2531fa1c..46225303624 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -12,11 +12,12 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { type: 'map', attributes: layoutAttributes, handleDefaults: handleDefaults, - partition: 'y' + partition: 'y', + fullData: fullData }); }; -function handleDefaults(containerIn, containerOut, coerce) { +function handleDefaults(containerIn, containerOut, coerce, opts) { coerce('style'); coerce('center.lon'); coerce('center.lat'); @@ -28,6 +29,115 @@ function handleDefaults(containerIn, containerOut, coerce) { var east = coerce('bounds.east'); var south = coerce('bounds.south'); var north = coerce('bounds.north'); + + // Auto-calculate bounds from data if not provided + if(west === undefined && east === undefined && south === undefined && north === undefined) { + var traceBounds = { + west: Infinity, + east: -Infinity, + south: Infinity, + north: -Infinity + }; + var hasTraceBounds = false; + + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + // Check if trace belongs to this map subplot + if(trace.visible !== true || trace.subplot !== opts.id) continue; + + // Handle scattermap traces + if(trace.type === 'scattermap') { + var lon = trace.lon || []; + var lat = trace.lat || []; + var len = Math.min(lon.length, lat.length); + + for(var j = 0; j < len; j++) { + var l = lon[j]; + var t = lat[j]; + if(l !== undefined && t !== undefined) { + traceBounds.west = Math.min(traceBounds.west, l); + traceBounds.east = Math.max(traceBounds.east, l); + traceBounds.south = Math.min(traceBounds.south, t); + traceBounds.north = Math.max(traceBounds.north, t); + hasTraceBounds = true; + } + } + } + // Add other map trace types here if needed (choroplethmap, etc) + } + + if(hasTraceBounds) { + var domain = containerOut.domain; + var width = layoutOut.width * (domain.x[1] - domain.x[0]); + var height = layoutOut.height * (domain.y[1] - domain.y[0]); + + // Default zoom calculation + if(containerOut.zoom === undefined) { + var padding = 0.1; // 10% padding + var dLon = Math.abs(traceBounds.east - traceBounds.west); + if(dLon === 0) dLon = 0.01; // Avoid division by zero + if(dLon > 360) dLon = 360; + + // Simple Mercator projection for lat + function mercatorY(lat) { + var rad = lat * Math.PI / 180; + return Math.log(Math.tan(Math.PI / 4 + rad / 2)); + } + + var yNorth = mercatorY(traceBounds.north); + var ySouth = mercatorY(traceBounds.south); + var dLat = Math.abs(yNorth - ySouth); + if(dLat === 0) dLat = 0.01; + + // 360 degrees = 2*PI radians. Mapbox world width is 512 pixels at zoom 0? + // Wait, Mapbox tile size is 512px by default in GL JS. + // 360 degrees fits in 512px at z=0? + // Let's use 360 degrees fits in 256 * 2^z (standard web mercator) + // Mapbox GL usually matches this. + // But let's be conservative. + + // Zoom for longitude fit: + // pixelWidth = (dLon / 360) * 512 * 2^z + // 2^z = (pixelWidth * 360) / (dLon * 512) + // z = log2(...) + var zLon = Math.log2((width * 360) / (dLon * 512)); + + // Zoom for latitude fit: + // pixelHeight = (dLat / (2 * Math.PI)) * 512 * 2^z + // 2^z = (pixelHeight * 2 * Math.PI) / (dLat * 512) + var zLat = Math.log2((height * 2 * Math.PI) / (dLat * 512)); + + var zoom = Math.min(zLon, zLat); + zoom = Math.max(0, Math.min(22, zoom)); // Clamp to valid zoom range + zoom -= padding; // Apply padding + + containerOut.zoom = zoom; + } + + // Default center calculation + if(containerOut.center.lon === undefined) { + containerOut.center.lon = (traceBounds.west + traceBounds.east) / 2; + } + if(containerOut.center.lat === undefined) { + // Center latitude in Mercator projection, then unproject + function mercatorY(lat) { + var rad = lat * Math.PI / 180; + return Math.log(Math.tan(Math.PI / 4 + rad / 2)); + } + function unMercatorY(y) { + return (2 * Math.atan(Math.exp(y)) - Math.PI / 2) * 180 / Math.PI; + } + + var yNorth = mercatorY(traceBounds.north); + var ySouth = mercatorY(traceBounds.south); + var yCenter = (yNorth + ySouth) / 2; + containerOut.center.lat = unMercatorY(yCenter); + } + + // Do NOT set containerOut.bounds here as that restricts panning! + } + } // End of auto-calculate bounds block + if( west === undefined || east === undefined || @@ -35,6 +145,12 @@ function handleDefaults(containerIn, containerOut, coerce) { north === undefined ) { delete containerOut.bounds; + + if(fitBounds) { + delete containerOut.center.lon; + delete containerOut.center.lat; + delete containerOut.zoom; + } } handleArrayContainerDefaults(containerIn, containerOut, { diff --git a/src/traces/scattermap/attributes.js b/src/traces/scattermap/attributes.js index 29c0cfcbb8a..985027ffac6 100644 --- a/src/traces/scattermap/attributes.js +++ b/src/traces/scattermap/attributes.js @@ -99,10 +99,10 @@ module.exports = overrideAll( line: { color: lineAttrs.color, - width: lineAttrs.width - - // TODO - // dash: dash + width: lineAttrs.width, + dash: extendFlat({}, scatterAttrs.line.dash, { + editType: 'plot' + }) }, connectgaps: scatterAttrs.connectgaps, diff --git a/src/traces/scattermap/convert.js b/src/traces/scattermap/convert.js index c2bfce0c56d..b22cc674674 100644 --- a/src/traces/scattermap/convert.js +++ b/src/traces/scattermap/convert.js @@ -69,7 +69,9 @@ module.exports = function convert(gd, calcTrace) { 'line-opacity': trace.opacity }); - // TODO convert line.dash into line-dasharray + if (trace.line.dash) { + line.paint['line-dasharray'] = convertDash(trace.line.dash, trace.line.width); + } } if (hasCircles) { @@ -197,6 +199,24 @@ function makeCircleOpts(calcTrace) { return s / 2; } + function convertDash(dash, width) { + var dashList; + if (dash === 'solid') dashList = []; + else if (dash === 'dot') dashList = [1, 2]; + else if (dash === 'dash') dashList = [4, 2]; + else if (dash === 'longdash') dashList = [8, 2]; + else if (dash === 'dashdot') dashList = [4, 2, 1, 2]; + else if (dash === 'longdashdot') dashList = [8, 2, 1, 2]; + else if (typeof dash === 'string') dashList = dash.split(/[\s,]+/).map(Number); + else dashList = dash; + + if (dashList.length) { + return dashList.map(function (v) { + return (v * width) / 2; + }); + } + } + var colorFn; if (arrayColor) { if (Colorscale.hasColorscale(trace, 'marker')) { diff --git a/src/traces/scattermapbox/attributes.js b/src/traces/scattermapbox/attributes.js index 2a41cea95cd..afc568ce1f2 100644 --- a/src/traces/scattermapbox/attributes.js +++ b/src/traces/scattermapbox/attributes.js @@ -99,10 +99,10 @@ module.exports = overrideAll( line: { color: lineAttrs.color, - width: lineAttrs.width - - // TODO - // dash: dash + width: lineAttrs.width, + dash: extendFlat({}, scatterAttrs.line.dash, { + editType: 'plot' + }) }, connectgaps: scatterAttrs.connectgaps, diff --git a/src/traces/scattermapbox/convert.js b/src/traces/scattermapbox/convert.js index 380324bbefb..1844b0099f7 100644 --- a/src/traces/scattermapbox/convert.js +++ b/src/traces/scattermapbox/convert.js @@ -69,7 +69,9 @@ module.exports = function convert(gd, calcTrace) { 'line-opacity': trace.opacity }); - // TODO convert line.dash into line-dasharray + if (trace.line.dash) { + line.paint['line-dasharray'] = convertDash(trace.line.dash, trace.line.width); + } } if (hasCircles) { @@ -197,6 +199,24 @@ function makeCircleOpts(calcTrace) { return s / 2; } + function convertDash(dash, width) { + var dashList; + if (dash === 'solid') dashList = []; + else if (dash === 'dot') dashList = [1, 2]; + else if (dash === 'dash') dashList = [4, 2]; + else if (dash === 'longdash') dashList = [8, 2]; + else if (dash === 'dashdot') dashList = [4, 2, 1, 2]; + else if (dash === 'longdashdot') dashList = [8, 2, 1, 2]; + else if (typeof dash === 'string') dashList = dash.split(/[\s,]+/).map(Number); + else dashList = dash; + + if (dashList.length) { + return dashList.map(function (v) { + return (v * width) / 2; + }); + } + } + var colorFn; if (arrayColor) { if (Colorscale.hasColorscale(trace, 'marker')) {