├── .gitignore ├── config.sample.js ├── map └── index.html ├── _assets ├── css │ ├── map.css │ └── form.css └── js │ ├── form.js │ ├── map.js │ ├── leaflet-heat.js │ └── particles.js ├── form └── index.html └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | config.js 2 | logo.png -------------------------------------------------------------------------------- /config.sample.js: -------------------------------------------------------------------------------- 1 | var CONFIG = { 2 | map: { 3 | lat: 50.85, 4 | lon: -0.2, 5 | zoom: 12, 6 | minOpacity: 0.4, 7 | radius: 15, 8 | max: 10, 9 | }, 10 | form: { 11 | id: "[Google form ID]", 12 | postcode: "[Postcode input name]", 13 | attendees: "[Attendees input name]", 14 | lat: "[Latitude input name]", 15 | lon: "[Longitude input name]" 16 | }, 17 | spreadsheet: { 18 | id: "[Google spreadsheet ID]", 19 | apiKey: "[Google spreadsheet api key]", 20 | sheet: "[Google spreadsheet sheet name]", 21 | range: "[Start cell]:[End cell]" 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /map/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Map 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 |
25 | 26 |
27 |

0 postcodes

28 |

0 attendees

29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /_assets/css/map.css: -------------------------------------------------------------------------------- 1 | /* Make the map fill the window */ 2 | /* Defaults */ 3 | 4 | html { 5 | box-sizing:border-box; 6 | } 7 | 8 | *, 9 | :after, 10 | :before { 11 | box-sizing:inherit; 12 | } 13 | 14 | html, 15 | body { 16 | margin: 0; 17 | padding: 0; 18 | height: 100%; 19 | } 20 | 21 | html { 22 | font-family: Helvetica, sans-serif; 23 | font-size: 16px; 24 | line-height: 1.5; 25 | } 26 | 27 | /* Set the map height to define the size of the map element */ 28 | 29 | #map { 30 | height: 100%; 31 | position: relative; 32 | z-index: 1; 33 | } 34 | 35 | /* stats */ 36 | 37 | .stats { 38 | position: absolute; 39 | bottom: 1rem; 40 | left: 1rem; 41 | z-index: 2; 42 | padding: 0.5rem 1rem; 43 | background: rgba(255, 255, 255, 0.6); 44 | } 45 | 46 | .stats p { 47 | margin: 0; 48 | padding: 0; 49 | font-size: 0.8rem; 50 | } 51 | 52 | /* log */ 53 | 54 | .log { 55 | position: absolute; 56 | bottom: 1rem; 57 | left: 33%; 58 | right: 33%; 59 | z-index: 2; 60 | margin: 0; 61 | max-height: 6rem; 62 | overflow: scroll; 63 | text-align: center; 64 | list-style: none; 65 | } 66 | 67 | .log li { 68 | background: rgba(255, 255, 255, 0.6); 69 | width: auto; 70 | margin-bottom: 0.2rem; 71 | opacity: 0; 72 | animation: log 6s linear 1; 73 | } 74 | 75 | @keyframes log { 76 | 0%, 75% { opacity: 1; } 77 | 100% { opacity: 0; } 78 | } 79 | 80 | /* timer */ 81 | 82 | .timer { 83 | position: absolute; 84 | top: 0; 85 | left: 0; 86 | right: 0; 87 | background: rgba(0, 0, 0, 0.3); 88 | height: 0.5rem; 89 | z-index: 2; 90 | } 91 | 92 | .timer.is-waiting::before { 93 | content: ""; 94 | position: absolute; 95 | width: 50%; 96 | left: 0; 97 | top: 0; 98 | height: 100%; 99 | background: rgba(255, 128, 0, 0.8); 100 | 101 | animation-name: timer; 102 | animation-duration: 30s; 103 | animation-timing-function: linear; 104 | animation-delay: 0s; 105 | animation-direction: normal; 106 | animation-iteration-count: 1; 107 | animation-fill-mode: forwards; 108 | animation-play-state: running; 109 | } 110 | 111 | @keyframes timer { 112 | from { width: 0; } 113 | to { width: 100%; } 114 | } -------------------------------------------------------------------------------- /form/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Attendee form 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 |
21 | 22 |

Welcome!

23 |

Please let us know where you've come from

24 |
25 | 26 |
27 |
28 | 31 | 32 |
33 |
34 | 37 | 38 |
39 |
40 | 41 | 42 |
43 | 44 |
45 |

Thank you!

46 |

You will be added to our map shortly...

47 |
48 |
49 | 50 |
51 | 52 |
53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Heatmap with Google Forms 2 | 3 | A custom HTML form that stores data in a Google Form, and uses this information to generate a heatmap using [Leaflet](https://leafletjs.com/) and [Leaflet.heat](https://github.com/Leaflet/Leaflet.heat). 4 | 5 | 6 | ## Set-up 7 | 8 | Create a config file by duplicating the file `config.sample.js` and calling it `config.js`. 9 | 10 | Customise the three blocks of values: 11 | 12 | 13 | ### Map 14 | 15 | - `lat`, `lon` and `zoom`: These are the default position for the map. 16 | - `minOpacity`, `radius` and `max`: These values change how the heatmap is rendered - see the [Leaflet.heat](https://github.com/Leaflet/Leaflet.heat) docs for an explanation. 17 | 18 | 19 | ### Form 20 | 21 | Create a new Google Form. 22 | 23 | The `id` field in the config should come from the URL of the form. 24 | 25 | The form should have four fields: 26 | 27 | - Postcode: This should be a text field 28 | - Lat and Lon: These fields should be optional. They won't be filled in by any users. Instead, [Postcodes.io](https://postcodes.io/) is used to geocode the postcode and retrieve a latitude and longitude. 29 | - Attendees: This will be a number, probably between 1-10. 30 | 31 | The four inputs *must* be in this order: postcode, lat, lon, attendees. 32 | 33 | Find out the IDs for these four input fields to add to the config. The easiest way to do this is to use the browser's webdev inspector. See [this link](https://github.com/jsdevel/google-form) for detail. They will probably be named `entry.xxxxx`. 34 | 35 | 36 | ### Spreadsheet 37 | 38 | Create a Google Spreadsheet from the Google Form responses. 39 | 40 | Make the spreadsheet visible publicly - read only. 41 | 42 | The `id` field in the config should come from the URL of the spreadsheet. 43 | 44 | Sign up for a new Google Spreadsheet API key - see [their documentation](https://developers.google.com/sheets/api/quickstart/js) for detail. You don't need a Client ID, just the [API key](https://console.developers.google.com/apis/credentials). 45 | 46 | While setting this up, it's worth restricting its use to specific domains, and only the _Google Sheets API_. 47 | 48 | The `sheet` field in the config should come from the name of the spreadsheet tab - this will be created automatically by the Google Form. Replace any spaces with _%20_, for example the value may be `Form%20Responses%201`. 49 | 50 | The `range` field in the config should include the fields that are populated by the form, for example `C2:E10000`. 51 | 52 | ### Logo 53 | 54 | Add an image file in the root called `logo.png` in order to add a logo to the front page of the form. 55 | 56 | --- 57 | 58 | ## Maintenance and support 59 | 60 | [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 61 | 62 | --- 63 | 64 | ## License 65 | 66 | This work is free. You can redistribute it and/or modify it under the 67 | terms of the Do What The Fuck You Want To Public License, Version 2, 68 | as published by Sam Hocevar. See http://www.wtfpl.net/ for more details. 69 | 70 | ``` 71 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 72 | Version 2, December 2004 73 | 74 | Copyright (C) 2004 Sam Hocevar 75 | 76 | Everyone is permitted to copy and distribute verbatim or modified 77 | copies of this license document, and changing it is allowed as long 78 | as the name is changed. 79 | 80 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 81 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 82 | 83 | 0. You just DO WHAT THE FUCK YOU WANT TO. 84 | 85 | ``` -------------------------------------------------------------------------------- /_assets/js/form.js: -------------------------------------------------------------------------------- 1 | (function(config) { 2 | 3 | // Use Google Forms for data storage 4 | // Geocode postcode data using https://postcodes.io/ 5 | // 6 | // https://github.com/jsdevel/google-form 7 | // https://www.codeproject.com/tips/721795/store-your-form-data-in-google-spreadsheet 8 | 9 | var wrapperEl; 10 | var formEl; 11 | var cancelEl; 12 | var inputEls; 13 | var postcodeEl; 14 | 15 | function init() { 16 | wrapper = document.querySelector('.Wrapper'); 17 | formEl = document.querySelector('.Form'); 18 | submitEl = formEl.querySelector('.Form-submit'); 19 | cancelEl = formEl.querySelector('.Form-cancel'); 20 | inputEls = formEl.querySelectorAll('.Form-input'); 21 | postcodeEl = formEl.querySelector('#postcode'); 22 | 23 | cancelEl.addEventListener('click', resetForm); 24 | formEl.addEventListener('submit', handleFormSubmit); 25 | 26 | inputEls.forEach(function(inputEl) { 27 | inputEl.addEventListener('input', checkInputValidity); 28 | }); 29 | 30 | inputEls[0].focus(); 31 | } 32 | 33 | function handleFormSubmit(e) { 34 | e.preventDefault(); 35 | convertPostcodeToLatLon(); 36 | } 37 | 38 | function checkInputValidity() { 39 | var valid = true; 40 | inputEls.forEach(function(inputEl) { 41 | if (!inputEl.validity.valid) { 42 | valid = false; 43 | } 44 | }); 45 | submitEl.disabled = !valid; 46 | } 47 | 48 | // attempt geocoding for postcode - but store response regardless of success 49 | function convertPostcodeToLatLon() { 50 | var postcode = postcodeEl.value; 51 | 52 | var url = 'https://api.postcodes.io/postcodes/' + postcode; 53 | 54 | request = new XMLHttpRequest(); 55 | request.open('GET', url, true); 56 | request.send(); 57 | request.onreadystatechange = handlePostcodeResponse; 58 | } 59 | 60 | function handlePostcodeResponse() { 61 | if (this.readyState === 4) { 62 | var latLon = null; 63 | 64 | // carry on anyway, but if we get a lat/lon then use it 65 | if (this.status === 200 || this.status === 0) { 66 | var response = JSON.parse(this.response); 67 | if (response.result && response.result.latitude && response.result.longitude) { 68 | latLon = { 69 | 'lat': response.result.latitude, 70 | 'lon': response.result.longitude 71 | } 72 | } 73 | } 74 | 75 | prepareFormData(latLon); 76 | } 77 | } 78 | 79 | function prepareFormData(latLon) { 80 | var data = ['submit=Submit']; 81 | 82 | if (latLon) { 83 | data.push(config.form.lat + '=' + latLon.lat); 84 | data.push(config.form.lon + '=' + latLon.lon); 85 | } 86 | 87 | inputEls.forEach(function(inputEl) { 88 | data.push(config.form[inputEl.id] + '=' + encodeURIComponent(inputEl.value)); 89 | }); 90 | 91 | var url = "https://docs.google.com/forms/d/" + config.form.id + '/formResponse?' + data.join('&'); 92 | 93 | processFormData(url, data); 94 | } 95 | 96 | function processFormData(url, data) { 97 | request = new XMLHttpRequest(); 98 | request.open('GET', url, true); 99 | request.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); 100 | request.send(); 101 | request.onreadystatechange = handleFormResponse; 102 | } 103 | 104 | function handleFormResponse() { 105 | if (this.readyState === 4) { 106 | // show success state regardless, but if there was an error we'd know here 107 | if (this.status !== 200 && this.status !== 0) { 108 | console.log(this); 109 | } 110 | 111 | showSuccess(); 112 | } 113 | } 114 | 115 | function showSuccess() { 116 | formEl.classList.add('is-complete'); 117 | setTimeout(resetForm, 2000); 118 | } 119 | 120 | function resetForm() { 121 | formEl.classList.remove('is-complete'); 122 | formEl.reset(); 123 | checkInputValidity(); 124 | inputEls[0].focus(); 125 | } 126 | 127 | document.addEventListener('DOMContentLoaded', init); 128 | 129 | })(window.CONFIG); 130 | -------------------------------------------------------------------------------- /_assets/js/map.js: -------------------------------------------------------------------------------- 1 | (function(config) { 2 | 3 | var map; 4 | var tiles; 5 | var heat; 6 | var hotspotCount = 0; 7 | 8 | var timerEl; 9 | var statPostcodesEl; 10 | var statAttendeesEl; 11 | var logEl; 12 | 13 | var options = { 14 | minOpacity: config.map.minOpacity, 15 | radius: config.map.radius, 16 | max: config.map.max, 17 | }; 18 | 19 | function init() { 20 | findEls(); 21 | createMap(); 22 | loadData(); 23 | 24 | // uncomment to test random heatmap population 25 | // setTimeout(addRandomPoint, 1000); 26 | } 27 | 28 | function findEls() { 29 | timerEl = document.querySelector('.timer'); 30 | statPostcodesEl = document.querySelector('.stat-postcodes'); 31 | statAttendeesEl = document.querySelector('.stat-attendees'); 32 | logEl = document.querySelector('.log'); 33 | } 34 | 35 | function createMap() { 36 | map = L.map('map').setView([config.map.lat, config.map.lon], config.map.zoom); 37 | 38 | tiles = L.tileLayer('https://{s}.tile.osm.org/{z}/{x}/{y}.png', { 39 | attribution: '© OpenStreetMap contributors', 40 | }).addTo(map); 41 | 42 | heat = L.heatLayer([], options).addTo(map); 43 | } 44 | 45 | function loadData() { 46 | timerEl.classList.remove('is-waiting'); 47 | 48 | var url = 'https://sheets.googleapis.com/v4/spreadsheets/' + config.spreadsheet.id + '/values/' + config.spreadsheet.sheet + '!' + config.spreadsheet.range + '?key=' + config.spreadsheet.apiKey; 49 | 50 | request = new XMLHttpRequest(); 51 | request.open('GET', url, true); 52 | request.send(); 53 | request.onreadystatechange = updateMapData; 54 | } 55 | 56 | function updateMapData() { 57 | if (this.readyState === 4) { 58 | if (this.status === 200 || this.status === 0) { 59 | try { 60 | var response = JSON.parse(this.response); 61 | if (response.values) { 62 | 63 | // extract just the new responses to update the map 64 | if (response.values.length > hotspotCount) { 65 | var hotspots = response.values.slice(hotspotCount); 66 | hotspotCount = response.values.length; 67 | updateMap(hotspots); 68 | } 69 | } 70 | } catch (e) { 71 | console.log(e); 72 | } 73 | } 74 | 75 | // update every 30 seconds 76 | setTimeout(loadData, 1000 * 30); 77 | timerEl.classList.add('is-waiting'); 78 | } 79 | } 80 | 81 | function updateMap(hotspots) { 82 | hotspots.forEach(function(hotspot) { 83 | logHotspot(hotspot); 84 | 85 | // remove postcode from data prior to plotting on map 86 | hotspot.shift(); 87 | heat.addLatLng(hotspot); 88 | }); 89 | } 90 | 91 | // hotspot array order: [postcode, lat, lon, attendees] 92 | function logHotspot(hotspot) { 93 | var postcodeValue = parseInt(statPostcodesEl.textContent, 10); 94 | statPostcodesEl.textContent = postcodeValue + 1; 95 | 96 | var attendeesValue = parseInt(statAttendeesEl.textContent, 10); 97 | statAttendeesEl.textContent = attendeesValue + parseInt(hotspot[3], 10); 98 | 99 | var logItemEl = document.createElement("li"); 100 | var logItemText = document.createTextNode(hotspot[0] + " added"); 101 | logItemEl.appendChild(logItemText); 102 | logEl.appendChild(logItemEl); 103 | 104 | setTimeout(emptyLog, 1000 * 10); 105 | } 106 | 107 | function emptyLog() { 108 | while (logEl.firstChild) { 109 | logEl.removeChild(logEl.firstChild); 110 | } 111 | } 112 | 113 | // test heatmap 114 | function addRandomPoint() { 115 | var lat = generateRandom(config.map.lat - 0.2, config.map.lat + 0.2, 8); 116 | var lng = generateRandom(config.map.lon - 0.2, config.map.lon + 0.2, 8); 117 | heat.addLatLng([lat, lng]); 118 | setTimeout(addRandomPoint, 1000); 119 | } 120 | 121 | function generateRandom(min, max, decimals) { 122 | var precision = '1' + '0'.repeat(decimals); 123 | return Math.floor(Math.random() * (max * precision - min * precision) + min * precision) / (1 * precision); 124 | } 125 | 126 | init(); 127 | })(window.CONFIG); 128 | -------------------------------------------------------------------------------- /_assets/css/form.css: -------------------------------------------------------------------------------- 1 | /* Defaults */ 2 | 3 | html { 4 | box-sizing:border-box; 5 | } 6 | 7 | *, 8 | :after, 9 | :before { 10 | box-sizing:inherit; 11 | } 12 | 13 | html, 14 | body { 15 | margin: 0; 16 | padding: 0; 17 | height: 100%; 18 | } 19 | 20 | html { 21 | font-family: Helvetica, sans-serif; 22 | font-size: 16px; 23 | } 24 | 25 | body { 26 | display: flex; 27 | align-items: center; 28 | justify-content: center; 29 | } 30 | 31 | /* background */ 32 | 33 | .Background { 34 | background: linear-gradient(90deg, #7fa5d7 0%, #6ac9cf 100%); 35 | position: fixed; 36 | z-index: 1; 37 | left: 0; 38 | right: 0; 39 | top: 0; 40 | bottom: 0; 41 | } 42 | 43 | /* Wrap */ 44 | 45 | .Wrapper { 46 | height: 90%; 47 | width: 90%; 48 | display: flex; 49 | flex-direction: column; 50 | align-items: stretch; 51 | justify-content: center; 52 | position: relative; 53 | z-index: 2; 54 | } 55 | 56 | @media (min-width: 62.5rem) { 57 | .Wrapper { 58 | flex-direction: row; 59 | } 60 | } 61 | 62 | .Welcome, 63 | .Form { 64 | color: #333; 65 | flex-direction: column; 66 | display: flex; 67 | align-items: center; 68 | justify-content: center; 69 | text-align: center; 70 | padding: 1rem; 71 | } 72 | 73 | /* Welcome */ 74 | 75 | .Welcome { 76 | background: rgba(173, 216, 230, 0.9); 77 | padding: 1rem; 78 | } 79 | 80 | @media (min-width: 62.5rem) { 81 | .Welcome { 82 | flex: 1; 83 | } 84 | } 85 | 86 | .Welcome-logo { 87 | max-width: 30%; 88 | margin-bottom: 0.5rem; 89 | } 90 | 91 | @media (min-width: 62.5rem) { 92 | .Welcome-logo { 93 | max-width: 90%; 94 | margin-bottom: 2rem; 95 | } 96 | } 97 | 98 | .Welcome-title { 99 | font-size: 1.2rem; 100 | margin: 0.5rem 0; 101 | } 102 | 103 | @media (min-width: 62.5rem) { 104 | .Welcome-title { 105 | font-size: 4rem; 106 | margin: 0.5rem 0; 107 | } 108 | } 109 | 110 | .Welcome-text { 111 | margin: 0.5rem 1rem; 112 | font-size: 1rem; 113 | line-height: 1.5; 114 | } 115 | 116 | @media (min-width: 62.5rem) { 117 | .Welcome-text { 118 | font-size: 1.6rem; 119 | } 120 | } 121 | 122 | /* Form */ 123 | 124 | .Form { 125 | position: relative; 126 | background: rgba(255, 255, 255, 0.9); 127 | flex: 1; 128 | justify-content: flex-start; 129 | } 130 | 131 | @media (min-width: 62.5rem) { 132 | .Form { 133 | justify-content: center; 134 | } 135 | } 136 | 137 | .Form-label { 138 | display: block; 139 | font-size: 1rem; 140 | margin: 0.5rem 0; 141 | color: #999; 142 | } 143 | 144 | @media (min-width: 62.5rem) { 145 | .Form-label { 146 | font-size: 1.2rem; 147 | } 148 | } 149 | 150 | .Form-input { 151 | font-size: 1.8rem; 152 | border: 0; 153 | border-bottom: 2px solid #6ac9cf; 154 | outline: none; 155 | margin-bottom: 2rem; 156 | padding: 0.5rem; 157 | text-align: center; 158 | width: 50%; 159 | } 160 | 161 | @media (min-width: 62.5rem) { 162 | .Form-input { 163 | font-size: 3rem; 164 | margin-bottom: 3rem; 165 | } 166 | } 167 | 168 | .Form-input:focus { 169 | border-bottom-color: rgba(255, 204, 0, 1); 170 | } 171 | 172 | .Form-submit { 173 | background: rgba(255, 204, 0, 1); 174 | -webkit-appearance: none; 175 | border: none; 176 | display: block; 177 | margin-bottom: 2rem; 178 | padding: 1rem 3rem; 179 | font-size: 1.4rem; 180 | cursor: pointer; 181 | transition: all 0.5s; 182 | } 183 | 184 | @media (min-width: 62.5rem) { 185 | .Form-submit { 186 | font-size: 1.2rem; 187 | padding: 1.5rem 6rem; 188 | } 189 | } 190 | 191 | .Form-submit:disabled { 192 | background: rgba(255, 204, 0, 0.3); 193 | pointer-events: none; 194 | } 195 | 196 | .Form-submit:focus, 197 | .Form-submit:active { 198 | outline: none; 199 | } 200 | 201 | .Form-cancel { 202 | background: rgba(255, 255, 255, 0.8); 203 | -webkit-appearance: none; 204 | border: none; 205 | padding: 1rem 3rem; 206 | font-size: 0.8rem; 207 | } 208 | 209 | .Form-complete { 210 | position: absolute; 211 | display: flex; 212 | flex-direction: column; 213 | align-items: center; 214 | justify-content: center; 215 | left: 0; 216 | right: 0; 217 | top: 0; 218 | bottom: 0; 219 | background: rgba(255, 255, 255, 0.95); 220 | z-index: 2; 221 | opacity: 0; 222 | visibility: hidden; 223 | transition: all 0.5s; 224 | } 225 | 226 | .Form.is-complete .Form-complete { 227 | opacity: 1; 228 | top: 0; 229 | visibility: visible; 230 | } 231 | 232 | .Form-completeTitle { 233 | font-size: 3rem; 234 | margin-bottom: 1rem; 235 | } 236 | 237 | .Form-completeText { 238 | font-size: 1rem; 239 | } -------------------------------------------------------------------------------- /_assets/js/leaflet-heat.js: -------------------------------------------------------------------------------- 1 | /* 2 | (c) 2014, Vladimir Agafonkin 3 | simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas 4 | https://github.com/mourner/simpleheat 5 | */ 6 | !function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/* 7 | (c) 2014, Vladimir Agafonkin 8 | Leaflet.heat, a tiny and fast heatmap plugin for Leaflet. 9 | https://github.com/Leaflet/Leaflet.heat 10 | */ 11 | L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)}; -------------------------------------------------------------------------------- /_assets/js/particles.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Particles 3 | * © 2008 4 | */ 5 | (function () { 6 | 7 | "use strict"; 8 | 9 | /* 10 | * global variables 11 | */ 12 | var 13 | // HTML canvas element 14 | canvas, 15 | 16 | // canvas draw context 17 | ctx, 18 | 19 | // collection of existing particles 20 | particles = [], 21 | 22 | // configurable options 23 | config = { 24 | 25 | // number of particles to draw 26 | particleCount : 50, 27 | 28 | // minimum distance for each particle to affect another 29 | minimumAffectingDistance : 50 30 | }; 31 | 32 | /* 33 | * init 34 | */ 35 | function init () { 36 | drawCanvas(); 37 | createParticles(); 38 | loop(); 39 | 40 | // resize canvas on page resize 41 | window.addEventListener("resize", function (event) { 42 | drawCanvas(); 43 | }); 44 | } 45 | 46 | 47 | /* 48 | * start redraw loop logic 49 | */ 50 | function loop () { 51 | clear(); 52 | update(); 53 | draw(); 54 | queue(); 55 | } 56 | 57 | /* 58 | * wipe canvas ready for next redraw 59 | */ 60 | function clear () { 61 | ctx.clearRect(0, 0, canvas.width, canvas.height); 62 | } 63 | 64 | /* 65 | * update particle positions 66 | */ 67 | function update () { 68 | 69 | // update each particle's position 70 | for (var count = 0; count < particles.length; count++) { 71 | 72 | var p = particles[count]; 73 | 74 | // Change the velocities 75 | p.x += p.vx; 76 | p.y += p.vy; 77 | 78 | // Bounce a particle that hits the edge 79 | if(p.x + p.radius > canvas.width || p.x - p.radius < 0) { 80 | p.vx = -p.vx; 81 | } 82 | 83 | if(p.y + p.radius > canvas.height || p.y - p.radius < 0) { 84 | p.vy = -p.vy; 85 | } 86 | 87 | // Check particle attraction 88 | for (var next = count + 1; next < particles.length; next++) { 89 | var p2 = particles[next]; 90 | calculateDistanceBetweenParticles(p, p2); 91 | } 92 | } 93 | } 94 | 95 | /* 96 | * update visual state - draw each particle 97 | */ 98 | function draw () { 99 | for (var count = 0; count < particles.length; count++) { 100 | var p = particles[count]; 101 | p.draw(); 102 | } 103 | } 104 | 105 | /* 106 | * prepare next redraw when the browser is ready 107 | */ 108 | function queue () { 109 | window.requestAnimationFrame(loop); 110 | } 111 | 112 | // go! 113 | init(); 114 | 115 | 116 | /* 117 | * Objects 118 | */ 119 | 120 | /* 121 | * Particle 122 | */ 123 | function Particle () { 124 | 125 | // Position particle 126 | this.x = Math.random() * canvas.width; 127 | this.y = Math.random() * canvas.height; 128 | 129 | // Give particle velocity, between -1 and 1 130 | this.vx = -1 + Math.random() * 2; 131 | this.vy = -1 + Math.random() * 2; 132 | 133 | // Give particle a radius 134 | this.radius = 4; 135 | 136 | // draw particle 137 | this.draw = function () { 138 | ctx.fillStyle = "white"; 139 | ctx.beginPath(); 140 | ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false); 141 | ctx.fill(); 142 | } 143 | } 144 | 145 | /* 146 | * Draw canvas 147 | */ 148 | function drawCanvas () { 149 | canvas = document.querySelector("canvas"); 150 | ctx = canvas.getContext("2d"); 151 | 152 | // set canvas to full page dimensions 153 | canvas.width = window.innerWidth; 154 | canvas.height = window.innerHeight; 155 | } 156 | 157 | /* 158 | * Create particles 159 | */ 160 | function createParticles () { 161 | for(var i = 0; i < config.particleCount; i++) { 162 | particles.push(new Particle()); 163 | } 164 | } 165 | 166 | 167 | /* 168 | * Distance calculator between two particles 169 | */ 170 | function calculateDistanceBetweenParticles (p1, p2) { 171 | 172 | var dist, 173 | dx = p1.x - p2.x, 174 | dy = p1.y - p2.y; 175 | 176 | dist = Math.sqrt(dx*dx + dy*dy); 177 | 178 | // Check whether distance is smaller than the min distance 179 | if(dist <= config.minimumAffectingDistance) { 180 | 181 | // set line opacity 182 | var opacity = 1 - dist/config.minimumAffectingDistance; 183 | 184 | // Draw connecting line 185 | ctx.beginPath(); 186 | ctx.strokeStyle = "rgba(255, 255, 255, " + opacity +")"; 187 | ctx.moveTo(p1.x, p1.y); 188 | ctx.lineTo(p2.x, p2.y); 189 | ctx.stroke(); 190 | ctx.closePath(); 191 | 192 | // Calculate particle acceleration 193 | var ax = dx / 2000, 194 | ay = dy / 2000; 195 | 196 | // Apply particle acceleration 197 | p1.vx -= ax; 198 | p1.vy -= ay; 199 | 200 | p2.vx += ax; 201 | p2.vy += ay; 202 | } 203 | } 204 | })(); 205 | --------------------------------------------------------------------------------