UNPKG

plotly.js

Version:

The open source javascript graphing library that powers plotly

855 lines (708 loc) 28 kB
'use strict'; /* global PlotlyGeoAssets:false */ var d3 = require('@plotly/d3'); var geo = require('d3-geo'); var geoPath = geo.geoPath; var geoDistance = geo.geoDistance; var geoProjection = require('d3-geo-projection'); var Registry = require('../../registry'); var Lib = require('../../lib'); var strTranslate = Lib.strTranslate; var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); var Fx = require('../../components/fx'); var Plots = require('../plots'); var Axes = require('../cartesian/axes'); const { concatExtremes, getAutoRange, makePadFn } = require('../cartesian/autorange'); var dragElement = require('../../components/dragelement'); var prepSelect = require('../../components/selections').prepSelect; var clearOutline = require('../../components/selections').clearOutline; var selectOnClick = require('../../components/selections').selectOnClick; var createGeoZoom = require('./zoom'); var constants = require('./constants'); var geoUtils = require('../../lib/geo_location_utils'); const { unwrapLonRange } = geoUtils; var topojsonUtils = require('../../lib/topojson_utils'); var topojsonFeature = require('topojson-client').feature; function Geo(opts) { this.id = opts.id; this.graphDiv = opts.graphDiv; this.container = opts.container; this.topojsonURL = opts.topojsonURL; this.isStatic = opts.staticPlot; this.topojsonName = null; this.topojson = null; this.projection = null; this.scope = null; this.viewInitial = null; this.fitScale = null; this.bounds = null; this.midPt = null; this.hasChoropleth = false; this.traceHash = {}; this.layers = {}; this.basePaths = {}; this.dataPaths = {}; this.dataPoints = {}; this.clipDef = null; this.clipRect = null; this.bgRect = null; this.makeFramework(); } var proto = Geo.prototype; module.exports = function createGeo(opts) { return new Geo(opts); }; proto.plot = function (geoCalcData, fullLayout, promises, replot) { var _this = this; if (replot) return _this.update(geoCalcData, fullLayout, true); _this._geoCalcData = geoCalcData; _this._fullLayout = fullLayout; var geoLayout = fullLayout[this.id]; var geoPromises = []; var needsTopojson = false; for (var k in constants.layerNameToAdjective) { if (k !== 'frame' && geoLayout['show' + k]) { needsTopojson = true; break; } } var hasMarkerAngles = false; for (var i = 0; i < geoCalcData.length; i++) { var trace = geoCalcData[0][0].trace; trace._geo = _this; if (trace.locationmode) { needsTopojson = true; } var marker = trace.marker; if (marker) { var angle = marker.angle; var angleref = marker.angleref; if (angle || angleref === 'north' || angleref === 'previous') hasMarkerAngles = true; } } this._hasMarkerAngles = hasMarkerAngles; if (needsTopojson) { var topojsonNameNew = topojsonUtils.getTopojsonName(geoLayout); if (_this.topojson === null || topojsonNameNew !== _this.topojsonName) { _this.topojsonName = topojsonNameNew; if (PlotlyGeoAssets.topojson[_this.topojsonName] === undefined) { geoPromises.push(_this.fetchTopojson()); } } } geoPromises = geoPromises.concat(geoUtils.fetchTraceGeoData(geoCalcData)); promises.push( new Promise(function (resolve, reject) { Promise.all(geoPromises) .then(function () { _this.topojson = PlotlyGeoAssets.topojson[_this.topojsonName]; _this.update(geoCalcData, fullLayout); resolve(); }) .catch(reject); }) ); }; proto.fetchTopojson = function () { var _this = this; var topojsonPath = topojsonUtils.getTopojsonPath(_this.topojsonURL, _this.topojsonName); return new Promise(function (resolve, reject) { d3.json(topojsonPath, function (err, topojson) { if (err) { if (err.status === 404) { return reject( new Error( [ 'plotly.js could not find topojson file at', topojsonPath + '.', 'Make sure the *topojsonURL* plot config option', 'is set properly.' ].join(' ') ) ); } else { return reject( new Error(['unexpected error while fetching topojson file at', topojsonPath].join(' ')) ); } } PlotlyGeoAssets.topojson[_this.topojsonName] = topojson; resolve(); }); }); }; proto.update = function (geoCalcData, fullLayout, replot) { var geoLayout = fullLayout[this.id]; // important: maps with choropleth traces have a different layer order this.hasChoropleth = false; for (var i = 0; i < geoCalcData.length; i++) { var calcTrace = geoCalcData[i]; var trace = calcTrace[0].trace; if (trace.type === 'choropleth') { this.hasChoropleth = true; } if (trace.visible === true && trace._length > 0) { trace._module.calcGeoJSON(calcTrace, fullLayout); } } if (!replot) { var hasInvalidBounds = this.updateProjection(geoCalcData, fullLayout); if (hasInvalidBounds) return; if (!this.viewInitial || this.scope !== geoLayout.scope) { this.saveViewInitial(geoLayout); } } this.scope = geoLayout.scope; this.updateBaseLayers(fullLayout, geoLayout); this.updateDims(fullLayout, geoLayout); this.updateFx(fullLayout, geoLayout); Plots.generalUpdatePerTraceModule(this.graphDiv, this, geoCalcData, geoLayout); var scatterLayer = this.layers.frontplot.select('.scatterlayer'); this.dataPoints.point = scatterLayer.selectAll('.point'); this.dataPoints.text = scatterLayer.selectAll('text'); this.dataPaths.line = scatterLayer.selectAll('.js-line'); var choroplethLayer = this.layers.backplot.select('.choroplethlayer'); this.dataPaths.choropleth = choroplethLayer.selectAll('path'); this._render(); }; proto.updateProjection = function (geoCalcData, fullLayout) { var gd = this.graphDiv; var geoLayout = fullLayout[this.id]; var gs = fullLayout._size; var domain = geoLayout.domain; var projLayout = geoLayout.projection; var lonaxis = geoLayout.lonaxis; var lataxis = geoLayout.lataxis; var axLon = lonaxis._ax; var axLat = lataxis._ax; var projection = (this.projection = getProjection(geoLayout)); // setup subplot extent [[x0,y0], [x1,y1]] var extent = [ [gs.l + gs.w * domain.x[0], gs.t + gs.h * (1 - domain.y[1])], [gs.l + gs.w * domain.x[1], gs.t + gs.h * (1 - domain.y[0])] ]; var center = geoLayout.center || {}; var rotation = projLayout.rotation || {}; var lonaxisRange = lonaxis.range || []; var lataxisRange = lataxis.range || []; if (geoLayout.fitbounds) { axLon._length = extent[1][0] - extent[0][0]; axLat._length = extent[1][1] - extent[0][1]; axLon.range = getAutoRange(gd, axLon); axLat.range = getAutoRange(gd, axLat); // Min/maxing the per-trace ranges above breaks when data crosses the // antimeridian, since `computeBbox` unwraps an east edge past 180°. Bounding // every coordinate at once lets `geoBounds` pick the compact range instead. const fitCoordParts = []; for (const calcTrace of geoCalcData) { const fitTrace = calcTrace[0].trace; if (fitTrace.visible !== true) continue; if (fitTrace._module.fitCoords) fitCoordParts.push(fitTrace._module.fitCoords(calcTrace, geoLayout)); } // Get the extents in the same manner as getAutoRange const lonExtremes = concatExtremes(gd, axLon); const lonDataMin = lonExtremes.min.reduce((min, { val }) => Math.min(min, val), Infinity); const lonDataMax = lonExtremes.max.reduce((max, { val }) => Math.max(max, val), -Infinity); const lonDataSpan = lonDataMax - lonDataMin; const fitBbox = geoUtils.boundsOfCoords(fitCoordParts.flat()); const [fitWest, , fitEast] = fitBbox || []; const fitSpan = fitEast - fitWest; const useFit = Boolean(fitBbox) && (lonDataSpan > 360 || (lonDataSpan < 360 && fitSpan < lonDataSpan)); // Add padding in the same manner as getAutoRange. Ideally this could use an // underlying helper function, but that doesn't exist yet so we handle it like this. if (useFit) { const getPadMin = makePadFn(fullLayout, axLon, 0); const getPadMax = makePadFn(fullLayout, axLon, 1); const padMin = lonExtremes.min.reduce((max, pt) => Math.max(max, getPadMin(pt)), 0); const padMax = lonExtremes.max.reduce((max, pt) => Math.max(max, getPadMax(pt)), 0); const usable = axLon._length - padMin - padMax; const paddedSpan = usable > axLon._length / 10 ? (fitSpan * axLon._length) / usable : fitSpan; const fitMid = (fitWest + fitEast) / 2; axLon.range = [fitMid - paddedSpan / 2, fitMid + paddedSpan / 2]; } var midLon = (axLon.range[0] + axLon.range[1]) / 2; var midLat = (axLat.range[0] + axLat.range[1]) / 2; if (geoLayout._isScoped) { center = { lon: midLon, lat: midLat }; } else if (geoLayout._isClipped) { center = { lon: midLon, lat: midLat }; rotation = { lon: midLon, lat: midLat, roll: rotation.roll }; var projType = projLayout.type; var lonHalfSpan = constants.lonaxisSpan[projType] / 2 || 180; var latHalfSpan = constants.lataxisSpan[projType] / 2 || 90; lonaxisRange = [midLon - lonHalfSpan, midLon + lonHalfSpan]; lataxisRange = [midLat - latHalfSpan, midLat + latHalfSpan]; } else { center = { lon: midLon, lat: midLat }; rotation = { lon: midLon, lat: rotation.lat, roll: rotation.roll }; } } // set 'pre-fit' projection projection .center([center.lon - rotation.lon, center.lat - rotation.lat]) .rotate([-rotation.lon, -rotation.lat, rotation.roll]) .parallels(projLayout.parallels); // fit projection 'scale' and 'translate' to set lon/lat ranges var rangeBox = makeRangeBox(lonaxisRange, lataxisRange); projection.fitExtent(extent, rangeBox); var b = (this.bounds = projection.getBounds(rangeBox)); var s = (this.fitScale = projection.scale()); var t = projection.translate(); // scaleExtent uses fitScale so min/maxscale are relative to the // user-facing projection.scale (where 1 == fits lon/lat ranges). // https://d3js.org/d3-zoom#zoom_scaleExtent projection.scaleExtent = () => { const { minscale } = projLayout; const maxscale = projLayout.maxscale ?? Infinity; // swap if user supplied min > max so d3 receives a valid range return [s * Math.min(minscale, maxscale), s * Math.max(minscale, maxscale)]; }; if (geoLayout.fitbounds) { var b2 = projection.getBounds(makeRangeBox(axLon.range, axLat.range)); var k2 = Math.min((b[1][0] - b[0][0]) / (b2[1][0] - b2[0][0]), (b[1][1] - b[0][1]) / (b2[1][1] - b2[0][1])); if (isFinite(k2)) { projection.scale(k2 * s); } else { Lib.warn('Something went wrong during' + this.id + 'fitbounds computations.'); } } else { // adjust projection to user setting projection.scale(projLayout.scale * s); } // px coordinates of view mid-point, // useful to update `geo.center` after interactions var midPt = (this.midPt = [(b[0][0] + b[1][0]) / 2, (b[0][1] + b[1][1]) / 2]); projection.translate([t[0] + (midPt[0] - t[0]), t[1] + (midPt[1] - t[1])]).clipExtent(b); // the 'albers usa' projection does not expose a 'center' method // so here's this hack to make it respond to 'geoLayout.center' if (geoLayout._isAlbersUsa) { var centerPx = projection([center.lon, center.lat]); // If center isn't within the Albers USA bounds (clipped to the USA), // `projection(...)` returns null so skip the recentering if (centerPx) { var tt = projection.translate(); projection.translate([tt[0] - (centerPx[0] - tt[0]), tt[1] - (centerPx[1] - tt[1])]); } } }; proto.updateBaseLayers = function (fullLayout, geoLayout) { var _this = this; var topojson = _this.topojson; var layers = _this.layers; var basePaths = _this.basePaths; function isAxisLayer(d) { return d === 'lonaxis' || d === 'lataxis'; } function isLineLayer(d) { return Boolean(constants.lineLayers[d]); } function isFillLayer(d) { return Boolean(constants.fillLayers[d]); } var allLayers = this.hasChoropleth ? constants.layersForChoropleth : constants.layers; var layerData = allLayers.filter(function (d) { return isLineLayer(d) || isFillLayer(d) ? geoLayout['show' + d] : isAxisLayer(d) ? geoLayout[d].showgrid : true; }); var join = _this.framework.selectAll('.layer').data(layerData, String); join.exit().each(function (d) { delete layers[d]; delete basePaths[d]; d3.select(this).remove(); }); join.enter() .append('g') .attr('class', function (d) { return 'layer ' + d; }) .each(function (d) { var layer = (layers[d] = d3.select(this)); if (d === 'bg') { _this.bgRect = layer.append('rect').style('pointer-events', 'all'); } else if (isAxisLayer(d)) { basePaths[d] = layer.append('path').style('fill', 'none'); } else if (d === 'backplot') { layer.append('g').classed('choroplethlayer', true); } else if (d === 'frontplot') { layer.append('g').classed('scatterlayer', true); } else if (isLineLayer(d)) { basePaths[d] = layer.append('path').style('fill', 'none').style('stroke-miterlimit', 2); } else if (isFillLayer(d)) { basePaths[d] = layer.append('path').style('stroke', 'none'); } }); join.order(); join.each(function (d) { var path = basePaths[d]; var adj = constants.layerNameToAdjective[d]; if (d === 'frame') { path.datum(constants.sphereSVG); } else if (isLineLayer(d) || isFillLayer(d)) { path.datum(topojsonFeature(topojson, topojson.objects[d])); } else if (isAxisLayer(d)) { path.datum(makeGraticule(d, geoLayout, fullLayout)) .call(Color.stroke, geoLayout[d].gridcolor) .call(Drawing.dashLine, geoLayout[d].griddash, geoLayout[d].gridwidth); } if (isLineLayer(d)) { path.call(Color.stroke, geoLayout[adj + 'color']).call(Drawing.dashLine, '', geoLayout[adj + 'width']); } else if (isFillLayer(d)) { path.call(Color.fill, geoLayout[adj + 'color']); } }); }; proto.updateDims = function (fullLayout, geoLayout) { var b = this.bounds; var hFrameWidth = (geoLayout.framewidth || 0) / 2; var l = b[0][0] - hFrameWidth; var t = b[0][1] - hFrameWidth; var w = b[1][0] - l + hFrameWidth; var h = b[1][1] - t + hFrameWidth; Drawing.setRect(this.clipRect, l, t, w, h); this.bgRect.call(Drawing.setRect, l, t, w, h).call(Color.fill, geoLayout.bgcolor); this.xaxis._offset = l; this.xaxis._length = w; this.yaxis._offset = t; this.yaxis._length = h; }; proto.updateFx = function (fullLayout, geoLayout) { var _this = this; var gd = _this.graphDiv; var bgRect = _this.bgRect; var dragMode = fullLayout.dragmode; var clickMode = fullLayout.clickmode; if (_this.isStatic) return; function zoomReset() { var viewInitial = _this.viewInitial; var updateObj = {}; for (var k in viewInitial) { updateObj[_this.id + '.' + k] = viewInitial[k]; } Registry.call('_guiRelayout', gd, updateObj); gd.emit('plotly_doubleclick', null); } function invert(lonlat) { return _this.projection.invert([lonlat[0] + _this.xaxis._offset, lonlat[1] + _this.yaxis._offset]); } var fillRangeItems = function (eventData, poly) { if (poly.isRect) { var ranges = (eventData.range = {}); ranges[_this.id] = [invert([poly.xmin, poly.ymin]), invert([poly.xmax, poly.ymax])]; } else { var dataPts = (eventData.lassoPoints = {}); dataPts[_this.id] = poly.map(invert); } }; // Note: dragOptions is needed to be declared for all dragmodes because // it's the object that holds persistent selection state. var dragOptions = { element: _this.bgRect.node(), gd: gd, plotinfo: { id: _this.id, xaxis: _this.xaxis, yaxis: _this.yaxis, fillRangeItems: fillRangeItems }, xaxes: [_this.xaxis], yaxes: [_this.yaxis], subplot: _this.id, clickFn: function (numClicks) { if (numClicks === 2) { clearOutline(gd); } } }; if (dragMode === 'pan') { bgRect.node().onmousedown = null; const zoom = createGeoZoom(_this, geoLayout); bgRect.call(zoom); // If the initial projection.scale lies outside [minscale, maxscale], // dispatch a synthetic zoom event to clamp it. Skip when re-entered // from inside a real zoom handler to avoid recursion. if (!d3.event) { const currScale = _this.projection.scale(); const [minExtent, maxExtent] = _this.projection.scaleExtent(); if (currScale < minExtent || currScale > maxExtent) zoom.event(bgRect); } bgRect.on('dblclick.zoom', zoomReset); if (!gd._context._scrollZoom.geo) { bgRect.on('wheel.zoom', null); } } else if (dragMode === 'select' || dragMode === 'lasso') { bgRect.on('.zoom', null); dragOptions.prepFn = function (e, startX, startY) { prepSelect(e, startX, startY, dragOptions, dragMode); }; dragElement.init(dragOptions); } bgRect.on('mousemove', function () { var lonlat = _this.projection.invert(Lib.getPositionFromD3Event()); if (!lonlat) { return dragElement.unhover(gd, d3.event); } _this.xaxis.p2c = function () { return lonlat[0]; }; _this.yaxis.p2c = function () { return lonlat[1]; }; Fx.hover(gd, d3.event, _this.id); }); bgRect.on('mouseout', function () { if (gd._dragging) return; dragElement.unhover(gd, d3.event); }); bgRect.on('click', function () { // For select and lasso the dragElement is handling clicks if (dragMode !== 'select' && dragMode !== 'lasso') { if (clickMode.indexOf('select') > -1) { selectOnClick(d3.event, gd, [_this.xaxis], [_this.yaxis], _this.id, dragOptions); } if (clickMode.indexOf('event') > -1) { // TODO: like pie and maps, this doesn't support right-click // actually this one is worse, as right-click starts a pan, or leaves // select in a weird state. // Also, only tangentially related, we should cancel hover during pan Fx.click(gd, d3.event); } } }); }; proto.makeFramework = function () { var _this = this; var gd = _this.graphDiv; var fullLayout = gd._fullLayout; var clipId = 'clip' + fullLayout._uid + _this.id; _this.clipDef = fullLayout._clips.append('clipPath').attr('id', clipId); _this.clipRect = _this.clipDef.append('rect'); _this.framework = d3 .select(_this.container) .append('g') .attr('class', 'geo ' + _this.id) .call(Drawing.setClipUrl, clipId, gd); // sane lonlat to px _this.project = function (v) { var px = _this.projection(v); return px ? [px[0] - _this.xaxis._offset, px[1] - _this.yaxis._offset] : [null, null]; }; _this.xaxis = { _id: 'x', c2p: function (v) { return _this.project(v)[0]; } }; _this.yaxis = { _id: 'y', c2p: function (v) { return _this.project(v)[1]; } }; // mock axis for hover formatting _this.mockAxis = { type: 'linear', showexponent: 'all', exponentformat: 'B' }; Axes.setConvert(_this.mockAxis, fullLayout); }; proto.saveViewInitial = function (geoLayout) { var center = geoLayout.center || {}; var projLayout = geoLayout.projection; var rotation = projLayout.rotation || {}; this.viewInitial = { fitbounds: geoLayout.fitbounds, 'projection.scale': projLayout.scale }; var extra; if (geoLayout._isScoped) { extra = { 'center.lon': center.lon, 'center.lat': center.lat }; } else if (geoLayout._isClipped) { extra = { 'projection.rotation.lon': rotation.lon, 'projection.rotation.lat': rotation.lat, 'center.lon': center.lon, 'center.lat': center.lat }; } else { extra = { 'center.lon': center.lon, 'center.lat': center.lat, 'projection.rotation.lon': rotation.lon }; } Lib.extendFlat(this.viewInitial, extra); }; proto.render = function (mayRedrawOnUpdates) { if (this._hasMarkerAngles && mayRedrawOnUpdates) { this.plot(this._geoCalcData, this._fullLayout, [], true); } else { this._render(); } }; // [hot code path] (re)draw all paths which depend on the projection proto._render = function () { var projection = this.projection; var pathFn = projection.getPath(); var k; function translatePoints(d) { var lonlatPx = projection(d.lonlat); return lonlatPx ? strTranslate(lonlatPx[0], lonlatPx[1]) : null; } function hideShowPoints(d) { return projection.isLonLatOverEdges(d.lonlat) ? 'none' : null; } for (k in this.basePaths) { this.basePaths[k].attr('d', pathFn); } for (k in this.dataPaths) { this.dataPaths[k].attr('d', function (d) { return pathFn(d.geojson); }); } for (k in this.dataPoints) { this.dataPoints[k].attr('display', hideShowPoints).attr('transform', translatePoints); // TODO: need to redraw points with marker angle instead of calling translatePoints } }; // Helper that wraps d3[geo + /* Projection name /*]() which: // // - adds 'getPath', 'getBounds' convenience methods // - scopes logic related to 'clipAngle' // - adds 'isLonLatOverEdges' method // - sets projection precision // - sets methods that aren't always defined depending // on the projection type to a dummy 'd3-esque' function, // // This wrapper alleviates subsequent code of (many) annoying if-statements. function getProjection(geoLayout) { var projLayout = geoLayout.projection; var projType = projLayout.type; var projName = constants.projNames[projType]; // uppercase the first letter and add geo to the start of method name projName = 'geo' + Lib.titleCase(projName); var projFn = geo[projName] || geoProjection[projName]; var projection = projFn(); var clipAngle = geoLayout._isSatellite ? (Math.acos(1 / projLayout.distance) * 180) / Math.PI : geoLayout._isClipped ? constants.lonaxisSpan[projType] / 2 : null; var methods = ['center', 'rotate', 'parallels', 'clipExtent']; var dummyFn = function (_) { return _ ? projection : []; }; for (var i = 0; i < methods.length; i++) { var m = methods[i]; if (typeof projection[m] !== 'function') { projection[m] = dummyFn; } } projection.isLonLatOverEdges = function (lonlat) { if (projection(lonlat) === null) { return true; } if (clipAngle) { var r = projection.rotate(); var angle = geoDistance(lonlat, [-r[0], -r[1]]); var maxAngle = (clipAngle * Math.PI) / 180; return angle > maxAngle; } else { return false; } }; projection.getPath = function () { return geoPath().projection(projection); }; projection.getBounds = function (object) { return projection.getPath().bounds(object); }; projection.precision(constants.precision); if (geoLayout._isSatellite) { projection.tilt(projLayout.tilt).distance(projLayout.distance); } if (clipAngle) { projection.clipAngle(clipAngle - constants.clipPad); } return projection; } function makeGraticule(axisName, geoLayout, fullLayout) { // equivalent to the d3 "ε" var epsilon = 1e-6; // same as the geoGraticule default var precision = 2.5; var axLayout = geoLayout[axisName]; var scopeDefaults = constants.scopeDefaults[geoLayout.scope]; var rng; var oppRng; var coordFn; if (axisName === 'lonaxis') { rng = scopeDefaults.lonaxisRange; oppRng = scopeDefaults.lataxisRange; coordFn = function (v, l) { return [v, l]; }; } else if (axisName === 'lataxis') { rng = scopeDefaults.lataxisRange; oppRng = scopeDefaults.lonaxisRange; coordFn = function (v, l) { return [l, v]; }; } var dummyAx = { type: 'linear', range: [rng[0], rng[1] - epsilon], tick0: axLayout.tick0, dtick: axLayout.dtick }; Axes.setConvert(dummyAx, fullLayout); var vals = Axes.calcTicks(dummyAx); // remove duplicate on antimeridian if (!geoLayout.isScoped && axisName === 'lonaxis') { vals.pop(); } var len = vals.length; var coords = new Array(len); for (var i = 0; i < len; i++) { var v = vals[i].x; var line = (coords[i] = []); for (var l = oppRng[0]; l < oppRng[1] + precision; l += precision) { line.push(coordFn(v, l)); } } return { type: 'MultiLineString', coordinates: coords }; } // Returns polygon GeoJSON corresponding to lon/lat range box // with well-defined direction // // Note that clipPad padding is added around range to avoid aliasing. function makeRangeBox(lon, lat) { var clipPad = constants.clipPad; const [lon0, lon1] = unwrapLonRange([lon[0] + clipPad, lon[1] - clipPad]); var lat0 = lat[0] + clipPad; var lat1 = lat[1] - clipPad; var dlon4 = (lon1 - lon0) / 4; return { type: 'Polygon', coordinates: [ [ [lon0, lat0], [lon0, lat1], [lon0 + dlon4, lat1], [lon0 + 2 * dlon4, lat1], [lon0 + 3 * dlon4, lat1], [lon1, lat1], [lon1, lat0], [lon1 - dlon4, lat0], [lon1 - 2 * dlon4, lat0], [lon1 - 3 * dlon4, lat0], [lon0, lat0] ] ] }; }