├── .bowerrc ├── .gitignore ├── Gruntfile.js ├── bower.json ├── license.txt ├── package.json ├── project.json ├── readme.rst ├── src ├── index.html ├── leaflet-map │ ├── _template.html │ ├── config-parser.js │ ├── factories │ │ ├── geojson.js │ │ ├── markers.js │ │ ├── tiles.js │ │ └── tilesets.js │ ├── factory.js │ ├── leaflet-map.js │ ├── leaflet-map.less │ └── parsers │ │ ├── geo-json.js │ │ ├── map-marker.js │ │ ├── map-options.js │ │ └── tile-layer.js └── states.geo.json └── tasks ├── build.js ├── bundle.js ├── connect.js ├── lib ├── browserify-less.js ├── browserify-template.js └── npm-less.js ├── publish.js └── watch.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "src/lib" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | temp/ 4 | src/lib/ 5 | auth.json 6 | package-lock.json -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 3 | //load tasks 4 | grunt.loadTasks("./tasks"); 5 | 6 | grunt.registerTask("default", ["bundle", "build", "connect", "watch"]); 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "component-leaflet-map", 3 | "version": "0.0.5", 4 | "homepage": "https://github.com/seattletimes/component-leaflet-map", 5 | "authors": [ 6 | "The Seattle Times" 7 | ], 8 | "license": "MIT", 9 | "ignore": [ 10 | "**/.*", 11 | "node_modules", 12 | "bower_components", 13 | "src/lib", 14 | "test", 15 | "tests" 16 | ], 17 | "dependencies": { 18 | "document-register-element": "~0.1.6" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | This program is free software: you can redistribute it and/or modify 2 | it under the terms of the GNU General Public License as published by 3 | the Free Software Foundation, either version 3 of the License, or 4 | (at your option) any later version. 5 | 6 | This program is distributed in the hope that it will be useful, 7 | but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | GNU General Public License for more details. 10 | 11 | You should have received a copy of the GNU General Public License 12 | along with this program. If not, see . -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "component-leaflet-map", 3 | "description": "A reusable DSL for creating maps with Leaflet", 4 | "version": "0.0.20", 5 | "author": "Thomas Wilburn", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/seattletimes/component-leaflet-map.git" 9 | }, 10 | "main": "src/leaflet-map/leaflet-map", 11 | "dependencies": { 12 | "component-responsive-frame": "^1.4.2", 13 | "dot": "^1.0.2", 14 | "leaflet": "^1.7.1", 15 | "less": "^2.0.0", 16 | "resolve": "^1.1.6" 17 | }, 18 | "devDependencies": { 19 | "async": "^0.9.0", 20 | "aws-sdk": "^2.0.0-rc.19", 21 | "browserify": "^16.5.0", 22 | "chalk": "^0.5.1", 23 | "exorcist": "^0.1.6", 24 | "grunt": "^1.0.4", 25 | "grunt-contrib-connect": "^2.0.0", 26 | "grunt-contrib-watch": "^1.1.0", 27 | "mime": "^1.2.11", 28 | "shelljs": "^0.3.0", 29 | "through2": "^0.6.3" 30 | }, 31 | "browser": { 32 | "leaflet": "leaflet/dist/leaflet.js" 33 | }, 34 | "browserify": { 35 | "transform": [ 36 | "./tasks/lib/browserify-template", 37 | "./tasks/lib/browserify-less" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /project.json: -------------------------------------------------------------------------------- 1 | { 2 | "s3": { 3 | "live": { 4 | "bucket": "apps.seattletimes.com", 5 | "path": "tags/leaflet-map" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /readme.rst: -------------------------------------------------------------------------------- 1 | component-leaflet-map 2 | ===================== 3 | 4 | A custom element for instantiating Leaflet maps and feeding them data. When finished, usage will look something like this:: 5 | 6 | 7 | 8 | Popup text! 9 | 10 | OR GEOJSON DATA GOES HERE 11 | 12 | 13 | 14 | 15 | 16 | { "stroke": false, "fillOpacity": 0.6 } 17 | 18 | 19 | I'm templated, with values from {{FEATURE_PROPERTY}} injected automatically! 20 | 21 | 22 | 23 | 24 | GeoJSON represents the most significant portion of ```` development, with the ability to automatically color and bind popups to the vector layer. The templating for popups is extremely primitive and only supports direct string value substitution (no loops, no expressions, no formatters), but this covers most of the required cases and provides predictable results for users. More advanced users will probably want to go through the ``map`` and ``leaflet`` properties exposed on the element anyway. 25 | 26 | Any elements with an ID will be made available after instantiation on the element's ``lookup`` property. For example, in the code above, we could manipulate the map marker via ``document.querySelector("leaflet-map").lookup.findMe``. Tile layers and GeoJSON layers are also available for manipulation this way. 27 | 28 | Currently, custom elements are upgraded asynchronously, which means that their contents may be momentarily visible in the DOM, especially if you have a lot of GeoJSON or configuration embedded. However, once the element has been upgraded, it will add a ``ready`` attribute for styling. The following CSS rule can be used to prevent a flash of unstyled content (FOUC):: 29 | 30 | leaflet-map:not([ready]) { display: none } 31 | 32 | ```` is built on top of our `component template `_. 33 | 34 | Elements 35 | ======== 36 | 37 | The Leaflet map is configured and initialized with a domain-specific language built out of custom tags, on the working assumption that it's easier to write HTML than it is to write JavaScript. The following tags are supported for setting up the map. Once it's initialized, you can access the map itself through the ``map`` property on the ```` element, and you can also get access to Leaflet itself through the ``leaflet`` property. 38 | 39 | The ```` element itself exposes several configuration properties via attributes, including: 40 | 41 | * ``lat`` and ``lng`` - center the map on the specified coordinate 42 | * ``zoom`` 43 | * ``fixed`` - disables zoom and pan functionality 44 | 45 | \ 46 | -------------- 47 | 48 | Tile layers can be initialized in two ways. You can set them up manually, by providing the ``url`` and ``subdomains`` attributes, or you can set ``layer`` to one of the following values in order to use a preset basemap. Many of the ``esri`` layers come in two parts: one for the background, and one for the labels, which makes it easier to create maps without distracting text details when adding data. 49 | 50 | * ``lite`` - Stamen Toner Lite 51 | * ``background`` - Stamen Toner Background 52 | * ``toner`` - Stamen Toner 53 | * ``watercolor`` - Stamen Watercolor 54 | * ``terrain`` - Stamen Terrain 55 | * ``esriStreets`` 56 | * ``esriTopographic`` 57 | * ``esriOceans`` 58 | * ``esriOceansLabels`` 59 | * ``esriNationalGeographic`` 60 | * ``esriDarkGray`` 61 | * ``esriDarkGrayLabels`` 62 | * ``esriGray`` 63 | * ``esriGrayLabels`` 64 | * ``esriImagery`` 65 | * ``esriImageryLabels`` 66 | * ``esriImageryTransportation`` 67 | * ``esriShadedRelief`` 68 | * ``esriShadedReliefLabels`` 69 | * ``esriTerrain`` 70 | * ``esriTerrainLabels`` 71 | * ``cartoPositron`` 72 | * ``cartoPositronBlank`` - No labels 73 | * ``cartoDarkMatter`` 74 | * ``cartoDarkMatterBlank`` - No labels 75 | 76 | ```` also supports the ``opacity`` attribute, in order to overlay basemaps on top of each other. 77 | 78 | \ 79 | -------------- 80 | 81 | Set the position of the map marker using the ``lat`` and ``lng`` attributes. Any classes on the ```` element will be set on the resulting Leaflet DivIcon marker. Content inside a ```` is bound to its popup. This makes these elements combine powerfully with EJS template loops, like so:: 82 | 83 | <% data.forEach(function(item) { %> 84 | 85 |

<%= item.title %>

86 |

<%= item.description %> 87 | 88 | <% }); %> 89 | 90 | \ 91 | ------------ 92 | 93 | The most complicated element, ```` uses several sub-elements to load and annotate GeoJSON files. You can provide the GeoJSON directly, using a ```` element (this is the template's default) or load it via AJAX by specifying a ``src`` attribute on the ````. 94 | 95 | The ```` element should contain strict JSON (e.g. all decimals should have leading zeros, property names should be double quoted, etc.) matching Leaflet's `path style options `_. These styles will be overridden/supplemented by any coloring specified in the ```` element, which is keyed via the ``property`` attribute to the properties hash on each GeoJSON feature. 96 | 97 | ```` allows you to bind HTML to the GeoJSON layer with some very simple templating, substituting in any property from the feature. Loops, conditionals, and formatting are not supported yet, so make sure your GeoJSON contains properly-formatted data to be used in the popup. 98 | 99 | \ 100 | --------------- 101 | 102 | In addition to the options exposed as ```` attributes, you can also set the configuration object for the map directly, by providing JSON matching the `Leaflet map options hash `_. 103 | 104 | Behind the scenes 105 | ================= 106 | 107 | The element breaks down its startup process into two parts, both of which take place during the custom element's ``createdCallback``. 108 | 109 | 1. Configuration parsing 110 | 2. Layer factories 111 | 112 | In the first step, the element and its contents are processed by the modules in the ``parsers`` directory. Tags inside the element are processed as a domain-specific language for various map features (they are not full-fledged custom elements). The parser modules are called with the config object as ``this`` and passed any elements inside the ```` that match the selectors defined in ``config-parser.js``, so that they can add their results to the configuration. 113 | 114 | The map and the configuration object are then passed to the factory module, which calls individual layer factories to consume the configuration and attach their layers to the map. Factories are also passed a reference to the custom element, so that they can perform any higher-level manipulation (such as attaching references to its ``lookup`` property when a layer has an ID attribute). 115 | 116 | At the end of startup, the ```` element will also have two properties available for consumption by external scripts: ``map`` contains the Leaflet instance inside the element, and ``leaflet`` contains the actual library, in case additional layers or utility functions need to be called. 117 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 28 | 29 | 30 |

Test page for leaflet-map

31 | 32 | 33 | 34 | Hello, Seattle! 35 | 36 | 37 | 40 | 41 | { 42 | "stroke": false, 43 | "fillOpacity": 0.6 44 | } 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | {{NAME}} - {{STATE}} 54 | 55 | 56 | 57 | 58 | 75 | 76 | -------------------------------------------------------------------------------- /src/leaflet-map/_template.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seattletimes/component-leaflet-map/055fda40a4065d12f22114a8a980f429b7ccc2a9/src/leaflet-map/_template.html -------------------------------------------------------------------------------- /src/leaflet-map/config-parser.js: -------------------------------------------------------------------------------- 1 | //All tag parsers have the config bound to this before being called with an 2 | //element matching their selector 3 | var parsers = { 4 | "tile-layer": require("./parsers/tile-layer"), 5 | "map-marker": require("./parsers/map-marker"), 6 | "geo-json": require("./parsers/geo-json"), 7 | "map-options": require("./parsers/map-options") 8 | }; 9 | 10 | module.exports = function(element) { 11 | var config = { 12 | tiles: [], 13 | geojson: [], 14 | kml: [], 15 | markers: [], 16 | options: {} 17 | }; 18 | //run through parsers 19 | for (var selector in parsers) { 20 | var elements = Array.prototype.slice.call(element.querySelectorAll(selector)); 21 | var parser = parsers[selector].bind(config); 22 | elements.forEach(parser); 23 | } 24 | 25 | //handle options on the element itself 26 | if (element.hasAttribute("lat")) { 27 | config.options.center = [element.getAttribute("lat"), element.getAttribute("lng")]; 28 | } else { 29 | config.options.center = [47.609, -122.333]; 30 | } 31 | if (!config.options.zoom) { 32 | config.options.zoom = element.getAttribute("zoom") || 7; 33 | } 34 | if (element.hasAttribute("fixed")) { 35 | config.options.boxZoom = false; 36 | config.options.doubleClickZoom = false; 37 | config.options.dragging = false; 38 | config.options.keyboard = false; 39 | config.options.scrollWheelZoom = false; 40 | config.options.touchZoom = false; 41 | config.options.zoomControl = false; 42 | config.options.tap = false; 43 | } 44 | return config; 45 | }; -------------------------------------------------------------------------------- /src/leaflet-map/factories/geojson.js: -------------------------------------------------------------------------------- 1 | var L = require("leaflet"); 2 | 3 | module.exports = function(map, config, element) { 4 | config.geojson.forEach(function(json) { 5 | var config = {}; 6 | if (json.style) config.style = json.style; 7 | if (json.eachFeature) config.onEachFeature = json.eachFeature; 8 | var makeLayer = function(data) { 9 | var layer = L.geoJson(data, config); 10 | layer.addTo(map); 11 | layer.bringToBack(); 12 | //add to lookup for later 13 | if (json.id) element.lookup[json.id] = layer; 14 | } 15 | if (json.src) { 16 | //get the data over AJAX 17 | var xhr = new XMLHttpRequest(); 18 | xhr.open("GET", json.src); 19 | xhr.onload = function() { 20 | var response = xhr.responseText; 21 | var data; 22 | try { 23 | data = JSON.parse(response); 24 | makeLayer(data); 25 | } catch (e) { 26 | console.error("Unable to parse GeoJSON from " + json.src); 27 | } 28 | }; 29 | xhr.send(); 30 | } else { 31 | makeLayer(json.data); 32 | } 33 | }); 34 | }; -------------------------------------------------------------------------------- /src/leaflet-map/factories/markers.js: -------------------------------------------------------------------------------- 1 | var L = require("leaflet"); 2 | 3 | module.exports = function(map, config, element) { 4 | 5 | config.markers.forEach(function(poi) { 6 | var options = { 7 | icon: new L.divIcon({ 8 | className: poi.class || "default-map-marker", 9 | iconSize: null 10 | }), 11 | title: poi.title 12 | }; 13 | var marker = L.marker(poi.latlng, options); 14 | if (poi.html) { 15 | marker.bindPopup(poi.html); 16 | } 17 | if (poi.id) { 18 | element.lookup[poi.id] = marker; 19 | } 20 | marker.addTo(map); 21 | }); 22 | 23 | }; -------------------------------------------------------------------------------- /src/leaflet-map/factories/tiles.js: -------------------------------------------------------------------------------- 1 | var tilesets = require("./tilesets"); 2 | var L = require("leaflet"); 3 | 4 | module.exports = function(map, config, element) { 5 | //if no tiles, set the toner 6 | if (!config.tiles || !config.tiles.length) { 7 | config.tiles = [{ layer: "toner" }]; 8 | } 9 | //convert tiles into layers 10 | var layers = config.tiles.forEach(function(setup) { 11 | setup.options = setup.options || {}; 12 | if (setup.layer && setup.layer in tilesets) { 13 | //discard and create one from the layer 14 | var tileset = tilesets[setup.layer]; 15 | setup.url = tileset.url; 16 | for (var original in tileset.options) { 17 | if (!setup.options[original]) setup.options[original] = tileset.options[original]; 18 | } 19 | } 20 | if (!setup.url) return undefined; 21 | var layer = L.tileLayer(setup.url, setup.options); 22 | //make these available in the element lookup for later 23 | if (setup.id) { 24 | element.lookup[setup.id] = layer; 25 | } 26 | layer.addTo(map); 27 | }); 28 | }; -------------------------------------------------------------------------------- /src/leaflet-map/factories/tilesets.js: -------------------------------------------------------------------------------- 1 | var stamenAttrib = [ 2 | 'Map tiles by Stamen Design, ', 3 | 'under CC BY 3.0. ', 4 | 'Data by OpenStreetMap, ', 5 | 'under CC BY SA.' 6 | ].join(""); 7 | 8 | var cartoAttrib = '© OpenStreetMap contributors, © CartoDB'; 9 | 10 | module.exports = { 11 | // STAMEN (no https tiles available as of 2018-06-06) 12 | lite: { 13 | url: "http://{s}.tile.stamen.com/toner-lite/{z}/{x}/{y}.png", 14 | options: { 15 | subdomains: "abcd", 16 | attribution: stamenAttrib 17 | } 18 | }, 19 | background: { 20 | url: "http://{s}.tile.stamen.com/toner-background/{z}/{x}/{y}.png", 21 | options: { 22 | subdomains: "abcd", 23 | attribution: stamenAttrib 24 | } 25 | }, 26 | toner: { 27 | url: "http://{s}.tile.stamen.com/toner/{z}/{x}/{y}.png", 28 | options: { 29 | subdomains: "abcd", 30 | attribution: stamenAttrib 31 | } 32 | }, 33 | watercolor: { 34 | url: "http://{s}.tile.stamen.com/watercolor/{z}/{x}/{y}.png", 35 | options: { 36 | subdomains: "abcd", 37 | attribution: stamenAttrib 38 | } 39 | }, 40 | terrain: { 41 | url: "http://{s}.tile.stamen.com/terrain/{z}/{x}/{y}.png", 42 | options: { 43 | subdomains: "abcd", 44 | attribution: stamenAttrib 45 | } 46 | }, 47 | // ESRI 48 | esriStreets: { 49 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", 50 | options: { 51 | minZoom: 1, 52 | maxZoom: 19, 53 | subdomains: ["server", "services"], 54 | attribution: "Esri" 55 | } 56 | }, 57 | esriTopographic: { 58 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", 59 | options: { 60 | minZoom: 1, 61 | maxZoom: 19, 62 | subdomains: ["server", "services"], 63 | attribution: "Esri" 64 | } 65 | }, 66 | esriOceans: { 67 | url: "https://{s}.arcgisonline.com/arcgis/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}", 68 | options: { 69 | minZoom: 1, 70 | maxZoom: 16, 71 | subdomains: ["server", "services"], 72 | attribution: "Esri" 73 | } 74 | }, 75 | esriOceansLabels: { 76 | url: "https://{s}.arcgisonline.com/arcgis/rest/services/Ocean/World_Ocean_Reference/MapServer/tile/{z}/{y}/{x}", 77 | options: { 78 | minZoom: 1, 79 | maxZoom: 16, 80 | subdomains: ["server", "services"] 81 | } 82 | }, 83 | esriNationalGeographic: { 84 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}", 85 | options: { 86 | minZoom: 1, 87 | maxZoom: 16, 88 | subdomains: ["server", "services"], 89 | attribution: "Esri" 90 | } 91 | }, 92 | esriDarkGray: { 93 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}", 94 | options: { 95 | minZoom: 1, 96 | maxZoom: 16, 97 | subdomains: ["server", "services"], 98 | attribution: "Esri, DeLorme, HERE" 99 | } 100 | }, 101 | esriDarkGrayLabels: { 102 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Reference/MapServer/tile/{z}/{y}/{x}", 103 | options: { 104 | minZoom: 1, 105 | maxZoom: 16, 106 | subdomains: ["server", "services"] 107 | } 108 | }, 109 | esriGray: { 110 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}", 111 | options: { 112 | minZoom: 1, 113 | maxZoom: 16, 114 | subdomains: ["server", "services"], 115 | attribution: "Esri, NAVTEQ, DeLorme" 116 | } 117 | }, 118 | esriGrayLabels: { 119 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}", 120 | options: { 121 | minZoom: 1, 122 | maxZoom: 16, 123 | subdomains: ["server", "services"] 124 | } 125 | }, 126 | esriImagery: { 127 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", 128 | options: { 129 | minZoom: 1, 130 | maxZoom: 19, 131 | subdomains: ["server", "services"], 132 | attribution: "Esri, DigitalGlobe, GeoEye, i-cubed, USDA, USGS, AEX, Getmapping, Aerogrid, IGN, IGP, swisstopo, and the GIS User Community" 133 | } 134 | }, 135 | esriImageryLabels: { 136 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", 137 | options: { 138 | minZoom: 1, 139 | maxZoom: 19, 140 | subdomains: ["server", "services"] 141 | } 142 | }, 143 | esriImageryTransportation: { 144 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}", 145 | options: { 146 | minZoom: 1, 147 | maxZoom: 19, 148 | subdomains: ["server", "services"] 149 | } 150 | }, 151 | esriShadedRelief: { 152 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}", 153 | options: { 154 | minZoom: 1, 155 | maxZoom: 13, 156 | subdomains: ["server", "services"], 157 | attribution: "ESRI, NAVTEQ, DeLorme" 158 | } 159 | }, 160 | esriShadedReliefLabels: { 161 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places_Alternate/MapServer/tile/{z}/{y}/{x}", 162 | options: { 163 | minZoom: 1, 164 | maxZoom: 12, 165 | subdomains: ["server", "services"] 166 | } 167 | }, 168 | esriTerrain: { 169 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/World_Terrain_Base/MapServer/tile/{z}/{y}/{x}", 170 | options: { 171 | minZoom: 1, 172 | maxZoom: 13, 173 | subdomains: ["server", "services"], 174 | attribution: "Esri, USGS, NOAA" 175 | } 176 | }, 177 | esriTerrainLabels: { 178 | url: "https://{s}.arcgisonline.com/ArcGIS/rest/services/Reference/World_Reference_Overlay/MapServer/tile/{z}/{y}/{x}", 179 | options: { 180 | minZoom: 1, 181 | maxZoom: 13, 182 | subdomains: ["server", "services"] 183 | } 184 | }, 185 | // CARTODB 186 | cartoPositron: { 187 | url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", 188 | options: { 189 | subdomains: "abc", 190 | attribution: cartoAttrib 191 | } 192 | }, 193 | cartoPositronBlank: { 194 | url: "https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png", 195 | options: { 196 | subdomains: "abc", 197 | attribution: cartoAttrib 198 | } 199 | }, 200 | cartoDarkMatter: { 201 | url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", 202 | options: { 203 | subdomains: "abc", 204 | attribution: cartoAttrib 205 | } 206 | }, 207 | cartoDarkMatterBlank: { 208 | url: "https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png", 209 | options: { 210 | subdomains: "abc", 211 | attribution: cartoAttrib 212 | } 213 | } 214 | }; 215 | -------------------------------------------------------------------------------- /src/leaflet-map/factory.js: -------------------------------------------------------------------------------- 1 | var factories = [ 2 | require("./factories/tiles"), 3 | require("./factories/geojson"), 4 | require("./factories/markers") 5 | ]; 6 | 7 | module.exports = { 8 | build: function(map, config, element) { 9 | factories.forEach(function(factory) { 10 | factory(map, config, element); 11 | }); 12 | }, 13 | addFactory: function(factory) { 14 | factories.push(factory); 15 | } 16 | }; -------------------------------------------------------------------------------- /src/leaflet-map/leaflet-map.js: -------------------------------------------------------------------------------- 1 | require("core-js/es6/reflect"); 2 | require('@webcomponents/custom-elements'); 3 | var L = require("leaflet"); 4 | var configParser = require("./config-parser"); 5 | var factory = require("./factory"); 6 | 7 | //styles 8 | require("./leaflet-map.less"); 9 | 10 | function LeafletMap() { 11 | return Reflect.construct(HTMLElement, [], this.constructor); 12 | } 13 | 14 | LeafletMap.prototype = Object.create(HTMLElement.prototype); 15 | LeafletMap.prototype.constructor = LeafletMap; 16 | Object.setPrototypeOf(LeafletMap, HTMLElement); 17 | 18 | LeafletMap.prototype.connectedCallback = function() { 19 | //read configuration from the element and its contents 20 | var config = configParser(this); 21 | 22 | //clear contents, set the ready attribute for CSS purposes 23 | this.innerHTML = ""; 24 | this.setAttribute("ready", ""); 25 | 26 | //initialize Leaflet 27 | var map = this.map = L.map(this, config.options); 28 | 29 | //set up the ID mapping object for factories to use if they want 30 | this.lookup = {}; 31 | 32 | //initialize layers via factories 33 | factory.build(map, config, this); 34 | 35 | } 36 | 37 | LeafletMap.prototype.leaflet = L; 38 | LeafletMap.prototype.map = null; 39 | 40 | customElements.define("leaflet-map", LeafletMap); 41 | -------------------------------------------------------------------------------- /src/leaflet-map/leaflet-map.less: -------------------------------------------------------------------------------- 1 | @import (inline) "npm://leaflet/dist/leaflet.css"; 2 | 3 | /* 4 | It's recommended to add the following rule to your stylesheet, in order to prevent FOUC 5 | leaflet-map[^ready] { display: none } 6 | */ 7 | 8 | leaflet-map { 9 | 10 | * { 11 | -webkit-box-sizing: border-box; 12 | -moz-box-sizing: border-box; 13 | -ms-box-sizing: border-box; 14 | box-sizing: border-box; 15 | } 16 | 17 | display: block; 18 | 19 | &:not([ready]) { 20 | display: none; 21 | } 22 | 23 | .default-map-marker { 24 | background: black; 25 | border: 1px solid #888; 26 | border-radius: 100%; 27 | width: 16px; 28 | height: 16px; 29 | margin-left: -8px; 30 | margin-top: -8px; 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /src/leaflet-map/parsers/geo-json.js: -------------------------------------------------------------------------------- 1 | module.exports = function(element) { 2 | if (!element.children.length) { 3 | //this is a raw GEOJSON source node, just parse it 4 | var src = element.innerHTML; 5 | try { 6 | src = JSON.parse(src); 7 | } catch (e) { 8 | if (src.length) console.warn("Error parsing GeoJSON:", src); 9 | src = null; 10 | } 11 | var json = { 12 | data: src, 13 | id: element.getAttribute("id"), 14 | src: element.getAttribute("src") 15 | }; 16 | this.geojson.push(json); 17 | } else { 18 | 19 | var url = element.getAttribute("src"); 20 | 21 | var data = element.querySelector("geo-data"); 22 | if (data) { 23 | try { 24 | data = JSON.parse(data.innerHTML); 25 | } catch(e) { 26 | console.error("Incorrect or missing geo-data element"); 27 | return; 28 | } 29 | } 30 | 31 | var style = element.querySelector("geo-style"); 32 | if (style) { 33 | try { 34 | style = JSON.parse(style.innerHTML); 35 | } catch(e) { 36 | console.error("Incorrect or missing geo-style element"); 37 | return; 38 | } 39 | } 40 | 41 | var palette = element.querySelector("geo-palette"); 42 | if (palette) { 43 | try { 44 | var prop = palette.getAttribute("property"); 45 | var mappings = palette.querySelectorAll("color-mapping"); 46 | var map = {}; 47 | var baseStyle = style || {}; 48 | for (var i = 0; i < mappings.length; i++) { 49 | var mapping = mappings[i]; 50 | var min = mapping.getAttribute("min") || -Infinity; 51 | var max = mapping.getAttribute("max") || Infinity; 52 | var color = mapping.getAttribute("color") || "pink"; 53 | map[color] = { min: min * 1, max: max * 1 }; 54 | } 55 | style = function(feature) { 56 | var value = feature.properties[prop]; 57 | var styleCopy = {}; 58 | for (var s in baseStyle) { 59 | styleCopy[s] = baseStyle[s]; 60 | } 61 | for (var color in map) { 62 | var range = map[color]; 63 | if (value >= range.min && value <= range.max) { 64 | styleCopy.fillColor = color; 65 | } 66 | } 67 | return styleCopy; 68 | }; 69 | } catch(e) { 70 | console.error("Incorrect or missing geo-palette element"); 71 | return; 72 | } 73 | } 74 | 75 | var popup = element.querySelector("geo-popup"); 76 | if (popup) { 77 | var template = popup.innerHTML; 78 | popup = function(feature, layer) { 79 | //WORLD'S WORST TEMPLATING 80 | var html = template; 81 | for (var key in feature.properties) { 82 | var val = feature.properties[key]; 83 | html = html.split("{{" + key + "}}").join(val); 84 | } 85 | layer.bindPopup(html); 86 | }; 87 | } 88 | 89 | this.geojson.push({ 90 | src: url, 91 | data: data, 92 | style: style, 93 | eachFeature: popup, 94 | id: element.getAttribute("id") 95 | }); 96 | } 97 | }; -------------------------------------------------------------------------------- /src/leaflet-map/parsers/map-marker.js: -------------------------------------------------------------------------------- 1 | module.exports = function(element) { 2 | this.markers.push({ 3 | html: element.innerHTML, 4 | latlng: [element.getAttribute("lat"), element.getAttribute("lng")].map(Number), 5 | style: element.getAttribute("style"), 6 | class: element.className, 7 | title: element.getAttribute("title"), 8 | id: element.getAttribute("id") 9 | }); 10 | }; -------------------------------------------------------------------------------- /src/leaflet-map/parsers/map-options.js: -------------------------------------------------------------------------------- 1 | module.exports = function(element) { 2 | var json; 3 | try { 4 | json = JSON.parse(element.innerHTML); 5 | } catch (e) { 6 | console.warn(e, element.innerHTML); 7 | } 8 | for (var key in json) { 9 | this.options[key] = json[key]; 10 | } 11 | }; -------------------------------------------------------------------------------- /src/leaflet-map/parsers/tile-layer.js: -------------------------------------------------------------------------------- 1 | module.exports = function(element) { 2 | this.tiles.push({ 3 | layer: element.getAttribute("layer"), 4 | url: element.getAttribute("url"), 5 | options: { 6 | subdomains: element.getAttribute("subdomains") || "", 7 | opacity: element.getAttribute("opacity") || 1, 8 | continuousWorld: element.hasAttribute("continuous"), 9 | noWrap: element.hasAttribute("nowrap"), 10 | tms: element.hasAttribute("tms") 11 | }, 12 | id: element.getAttribute("id") 13 | }); 14 | }; -------------------------------------------------------------------------------- /tasks/build.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Build HTML files using any data loaded onto the shared state. 4 | 5 | */ 6 | 7 | var path = require("path"); 8 | var shell = require("shelljs"); 9 | 10 | module.exports = function(grunt) { 11 | 12 | grunt.template.include = function(where, data) { 13 | var file = grunt.file.read(path.resolve("src/", where)); 14 | return grunt.template.process(file, {data: data || grunt.data}); 15 | }; 16 | 17 | grunt.registerTask("build", "Processes index.html using shared data (if available)", function() { 18 | var files = grunt.file.expandMapping(["**/*.html", "!**/_*.html", "!lib/**/*.html"], "build", { cwd: "src" }); 19 | var data = Object.create(grunt.data || {}); 20 | data.t = grunt.template; 21 | files.forEach(function(file) { 22 | var src = file.src.shift(); 23 | var input = grunt.file.read(src); 24 | var output = grunt.template.process(input, { data: data }); 25 | grunt.file.write(file.dest, output); 26 | }); 27 | 28 | //also copy our sole data file over for testing 29 | shell.cp("src/states.geo.json", "build"); 30 | }); 31 | 32 | }; -------------------------------------------------------------------------------- /tasks/bundle.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Builds a source package for all components located in this package 4 | 5 | */ 6 | 7 | var async = require("async"); 8 | var browserify = require("browserify"); 9 | var exorcist = require("exorcist"); 10 | var fs = require("fs"); 11 | var path = require("path"); 12 | 13 | module.exports = function(grunt) { 14 | 15 | grunt.registerTask("bundle", "Compile build/app.js using Browserify", function() { 16 | var done = this.async(); 17 | 18 | //set name, out for each seed file 19 | var components = grunt.file.expand({filter: "isDirectory", cwd: "src" }, ["*", "!lib"]); 20 | var files = components.map(function(d) { return path.join(d, d) }); 21 | files.filter(function(f) { grunt.file.exists(f) }); 22 | 23 | grunt.file.mkdir("build"); 24 | 25 | async.each(files, function(file, c) { 26 | var output = "build/" + path.basename(file) + ".js"; 27 | 28 | var outStream = fs.createWriteStream(output); 29 | 30 | var b = browserify({ debug: true, standalone: "leaflet-map" }); 31 | 32 | b.add("./src/" + file); 33 | b.bundle().pipe(exorcist(output + ".map")).pipe(outStream).on("finish", function() { 34 | c(); 35 | }); 36 | 37 | }, function(err) { 38 | if (err) console.log(err); 39 | done(); 40 | }); 41 | 42 | }); 43 | 44 | }; 45 | -------------------------------------------------------------------------------- /tasks/connect.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Sets up a connect server to work from the /build folder. May also set up a 4 | livereload server at some point. 5 | 6 | */ 7 | 8 | module.exports = function(grunt) { 9 | 10 | grunt.loadNpmTasks("grunt-contrib-connect"); 11 | 12 | grunt.config.merge({ 13 | connect: { 14 | dev: { 15 | options: { 16 | livereload: true, 17 | base: "./build" 18 | } 19 | } 20 | } 21 | }) 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /tasks/lib/browserify-less.js: -------------------------------------------------------------------------------- 1 | var less = require("less"); 2 | var path = require("path"); 3 | var through = require("through2"); 4 | 5 | var npmImporter = require("./npm-less"); 6 | 7 | var _ = function(tmp) { 8 | return tmp.toString().replace(/^.*\/\*\n*\s*|\*\/\}$/gm, ""); 9 | }; 10 | 11 | var lessTemplate = function() {/* 12 | var style = document.createElement("style"); 13 | style.setAttribute("less", "{{file}}"); 14 | style.innerHTML = {{content}}; 15 | document.head.appendChild(style); 16 | */}; 17 | 18 | module.exports = function(file) { 19 | 20 | if (path.extname(file).match(/\.(css|less)/)) { 21 | var buffer = ""; 22 | return through(function(chunk, enc, done) { 23 | buffer += chunk.toString(); 24 | done(); 25 | }, function(done) { 26 | var self = this; 27 | var options = { 28 | paths: [ path.dirname(file) ], 29 | plugins: [ npmImporter ] 30 | }; 31 | 32 | less.render(buffer, options, function(err, result) { 33 | if (err) { 34 | console.error(err); 35 | return done(); 36 | } 37 | var output = _(lessTemplate).replace("{{file}}", path.basename(file)).replace("{{content}}", JSON.stringify(result.css)); 38 | self.push(output); 39 | self.push(null); 40 | buffer = ""; 41 | done(); 42 | }); 43 | }); 44 | } 45 | 46 | return through(); 47 | 48 | }; -------------------------------------------------------------------------------- /tasks/lib/browserify-template.js: -------------------------------------------------------------------------------- 1 | var dot = require("dot"); 2 | var path = require("path"); 3 | var through = require("through2"); 4 | 5 | var extensions = [".html", ".txt"]; 6 | 7 | dot.templateSettings.varname = "data"; 8 | dot.templateSettings.selfcontained = true; 9 | 10 | module.exports = function(file) { 11 | 12 | if (extensions.indexOf(path.extname(file)) > -1) { 13 | var buffer = []; 14 | return through(function(chunk, enc, done) { 15 | buffer.push(chunk.toString()); 16 | done(); 17 | }, function(done) { 18 | var text = buffer.join(""); 19 | buffer = []; 20 | 21 | var template = dot.compile(text); 22 | 23 | this.push("module.exports = " + template.toString()); 24 | this.push(null); 25 | done(); 26 | }); 27 | } 28 | 29 | return through(); 30 | 31 | }; -------------------------------------------------------------------------------- /tasks/lib/npm-less.js: -------------------------------------------------------------------------------- 1 | // we need to plug into the LESS import statement for node modules 2 | // Sadly, the less-plugin-npm-import module doesn't handle this well 3 | // It assumes that we have all our dependencies at the top level 4 | var resolve = require("resolve").sync; 5 | 6 | var npmImporter = { 7 | install: function(less, pluginManager) { 8 | 9 | var FileManager = function() {}; 10 | FileManager.prototype = new less.FileManager(); 11 | FileManager.prototype.supports = function(file, dir) { 12 | return file.indexOf("npm://") == 0; 13 | }; 14 | FileManager.prototype.supportsSync = FileManager.prototype.supports; 15 | FileManager.prototype.resolve = function(file) { 16 | file = file.replace("npm://", ""); 17 | try { 18 | var resolved = resolve(file, { 19 | extensions: [".less", ".css"], 20 | packageFilter: function(package) { 21 | if (package.style) package.main = package.style; 22 | return package; 23 | } 24 | }); 25 | return resolved; 26 | } catch (err) { 27 | console.log(err); 28 | } 29 | }; 30 | FileManager.prototype.loadFile = function(url, dir, options, env) { 31 | var filename = this.resolve(url); 32 | return less.FileManager.prototype.loadFile.call(this, filename, "", options, env); 33 | }; 34 | FileManager.prototype.loadFileSync = function(url, dir, options, env) { 35 | var filename = this.resolve(url); 36 | return less.FileManager.prototype.loadFileSync.call(this, filename, "", options, env); 37 | }; 38 | 39 | pluginManager.addFileManager(new FileManager()); 40 | }, 41 | minVersion: [2, 1, 1] 42 | }; 43 | 44 | module.exports = npmImporter; -------------------------------------------------------------------------------- /tasks/publish.js: -------------------------------------------------------------------------------- 1 | var async = require("async"); 2 | var fs = require("fs"); 3 | var path = require("path"); 4 | var chalk = require("chalk"); 5 | var gzip = require("zlib").gzip; 6 | var mime = require("mime"); 7 | var join = function() { 8 | return path.join.apply(path, arguments).replace(/\\/g, "/"); 9 | }; 10 | 11 | var aws = require("aws-sdk"); 12 | 13 | var formatSize = function(input) { 14 | if (input > 1024 * 1024) { 15 | return Math.round(input * 10 / (1024 * 1024)) / 10 + "MB"; 16 | } 17 | if (input > 1024) { 18 | return Math.round(input / 1024) + "KB"; 19 | } 20 | return input + "B"; 21 | }; 22 | 23 | var gzippable = ["js", "html", "json", "map", "css", "txt", "csv", "svg", "geojson"]; 24 | 25 | module.exports = function(grunt) { 26 | 27 | var config = require("../project.json"); 28 | 29 | var findBuiltFiles = function() { 30 | var pattern = ["**/*"]; 31 | var embargo = config.embargo; 32 | if (embargo) { 33 | if (!(embargo instanceof Array)) embargo = [embargo]; 34 | embargo.forEach(function(item) { 35 | pattern.push("!" + item); 36 | console.log(chalk.bgRed.white("File embargoed: %s"), item); 37 | }); 38 | } 39 | var files = grunt.file.expand({ cwd: "build", filter: "isFile" }, pattern); 40 | var list = files.map(function(file) { 41 | var buffer = fs.readFileSync(path.join("build", file)); 42 | return { 43 | path: file, 44 | buffer: buffer 45 | }; 46 | }); 47 | return list; 48 | }; 49 | 50 | grunt.registerTask("publish", "Pushes the build folder to S3", function(deploy) { 51 | 52 | var done = this.async(); 53 | 54 | deploy = deploy || "stage"; 55 | 56 | if (deploy == "simulated") { 57 | var uploads = findBuiltFiles(); 58 | async.each(uploads, function(upload, c) { 59 | var extension = upload.path.split(".").pop(); 60 | if (gzippable.indexOf(extension) > -1) { 61 | gzip(upload.buffer, function(err, zipped) { 62 | console.log("Uploading gzipped %s - %s => %s", 63 | upload.path, 64 | formatSize(upload.buffer.length), 65 | formatSize(zipped.length) 66 | ); 67 | c(); 68 | }) 69 | } else { 70 | console.log("Uploading %s", upload.path); 71 | c(); 72 | } 73 | }, done); 74 | return; 75 | } 76 | 77 | var bucketConfig = config.s3[deploy]; 78 | //strip slashes for safety 79 | bucketConfig.path = bucketConfig.path.replace(/^\/|\/$/g, ""); 80 | 81 | var creds = { 82 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 83 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 84 | region: "us-west-2" 85 | }; 86 | if (!creds.accessKeyId) { 87 | creds = require("../auth.json").s3; 88 | } 89 | aws.config.update(creds); 90 | 91 | var s3 = new aws.S3(); 92 | s3.createBucket({ 93 | Bucket: bucketConfig.bucket 94 | }, function(err) { 95 | if (err && err.code != "BucketAlreadyOwnedByYou") { 96 | return console.log(err); 97 | } 98 | var uploads = findBuiltFiles(); 99 | async.eachLimit(uploads, 10, function(upload, c) { 100 | var obj = { 101 | Bucket: bucketConfig.bucket, 102 | Key: join(bucketConfig.path, upload.path.replace(/^\\?build/, "")), 103 | Body: upload.buffer, 104 | ACL: "public-read", 105 | ContentType: mime.lookup(upload.path), 106 | CacheControl: "public,max-age=300" 107 | }; 108 | //if this matches GZip support, compress them before uploading to S3 109 | var extension = upload.path.split(".").pop(); 110 | if (gzippable.indexOf(extension) > -1) { 111 | var before = upload.buffer.length; 112 | return gzip(upload.buffer, function(err, zipped) { 113 | if (!err) { 114 | obj.Body = zipped; 115 | var after = zipped.length; 116 | obj.ContentEncoding = "gzip"; 117 | console.log("Uploading gzipped %s - %s %s %s (%s)", 118 | obj.Key, 119 | 120 | chalk.cyan(formatSize(before)), 121 | chalk.yellow("=>"), 122 | chalk.cyan(formatSize(after)), 123 | chalk.bold.green(Math.round(after / before * 100) + "%") 124 | ); 125 | s3.putObject(obj, c); 126 | } 127 | }); 128 | } 129 | console.info("Uploading", obj.Key); 130 | s3.putObject(obj, c); 131 | }, function(err) { 132 | if (err) return console.log(err); 133 | console.log("All files uploaded successfully"); 134 | done(); 135 | }); 136 | }); 137 | }); 138 | 139 | }; 140 | -------------------------------------------------------------------------------- /tasks/watch.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Standard configuration from grunt-contrib-watch: 4 | - Compile LESS when source files change 5 | - Compile page template when index.html changes 6 | - Compile JSON data when CSVs change 7 | - (optional) Run optimizer on AMD modules when JS files change 8 | 9 | */ 10 | 11 | module.exports = function(grunt) { 12 | 13 | grunt.loadNpmTasks("grunt-contrib-watch"); 14 | 15 | grunt.config.merge({ 16 | watch: { 17 | options: { 18 | livereload: true 19 | }, 20 | templates: { 21 | files: ["src/**/*.html"], //test files for local development 22 | tasks: ["build"] 23 | }, 24 | js: { 25 | files: ["src/**/*"], //everything, due to templating, GLSL, LESS, etc. 26 | tasks: ["bundle"] 27 | } 28 | } 29 | }); 30 | 31 | }; 32 | --------------------------------------------------------------------------------