├── .gitignore ├── .DS_Store ├── js ├── .DS_Store ├── h-gmap.js ├── crimeAnimator.js └── h-map.js ├── img ├── white.jpg └── favicon.ico ├── crime-heatmap.gif ├── views ├── .DS_Store └── index.non-compression.html ├── schema.sql ├── README.md ├── LICENSE ├── styles.css └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidhampgonsalves/crime-heatmaps/HEAD/.DS_Store -------------------------------------------------------------------------------- /js/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidhampgonsalves/crime-heatmaps/HEAD/js/.DS_Store -------------------------------------------------------------------------------- /img/white.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidhampgonsalves/crime-heatmaps/HEAD/img/white.jpg -------------------------------------------------------------------------------- /crime-heatmap.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidhampgonsalves/crime-heatmaps/HEAD/crime-heatmap.gif -------------------------------------------------------------------------------- /img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidhampgonsalves/crime-heatmaps/HEAD/img/favicon.ico -------------------------------------------------------------------------------- /views/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidhampgonsalves/crime-heatmaps/HEAD/views/.DS_Store -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | create table crimes_by_year ( 2 | year int not null, 3 | crimes jsonb default '[]', 4 | primary key(year) 5 | ); 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Halifax Crime Heatmap 4 | 5 | [Halifix Crime Heatmap](https://www.davidhampgonsalves.com/crime-heatmaps/) is an HTML5 animated heatmap visualization of crime for the city of Halifax. 6 | 7 | It was created as an entry for the [Apps4Halifax](http://www.apps4halifax.ca/) contest and recieved second place in its category. 8 | 9 | ## Current Status 10 | 11 | This project is in a working but shuttered state. I've stood it back up and converted many times but the latest (and final) was from Node/Postgres backend to static HTML. This was done to preserve it here with data from 2015-2017. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 david hamp-gonsalves 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /js/h-gmap.js: -------------------------------------------------------------------------------- 1 | /* 2 | * heatmap.js GMaps overlay 3 | * 4 | * Copyright (c) 2011, Patrick Wied (http://www.patrick-wied.at) 5 | * Dual-licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) 6 | * and the Beerware (http://en.wikipedia.org/wiki/Beerware) license. 7 | */ 8 | 9 | function HeatmapOverlay(map, cfg){ 10 | var me = this; 11 | 12 | me.heatmap = null; 13 | me.conf = cfg; 14 | me.latlngs = []; 15 | me.bounds = null; 16 | me.setMap(map); 17 | 18 | //google.maps.event.addListener(map, 'idle', function() { me.draw() }); 19 | } 20 | 21 | HeatmapOverlay.prototype = new google.maps.OverlayView(); 22 | 23 | HeatmapOverlay.prototype.onAdd = function(){ 24 | 25 | var panes = this.getPanes(), 26 | w = this.getMap().getDiv().clientWidth, 27 | h = this.getMap().getDiv().clientHeight, 28 | el = document.createElement("div"); 29 | 30 | el.style.cssText = "position:absolute;top:0;left:0;width:"+w+"px;height:"+h+"px;"; 31 | 32 | this.conf.element = el; 33 | panes.overlayLayer.appendChild(el); 34 | 35 | this.heatmap = h337.create(this.conf); 36 | } 37 | 38 | HeatmapOverlay.prototype.onRemove = function(){ 39 | // Empty for now. 40 | } 41 | 42 | HeatmapOverlay.prototype.draw = function(){ 43 | console.log('draw called'); 44 | return; 45 | 46 | /*var me = this, 47 | overlayProjection = me.getProjection(), 48 | currentBounds = me.map.getBounds(); 49 | 50 | if (currentBounds.equals(me.bounds)) { 51 | return; 52 | } 53 | me.bounds = currentBounds; 54 | 55 | var ne = overlayProjection.fromLatLngToDivPixel(currentBounds.getNorthEast()), 56 | sw = overlayProjection.fromLatLngToDivPixel(currentBounds.getSouthWest()), 57 | topY = ne.y, 58 | leftX = sw.x, 59 | h = sw.y - ne.y, 60 | w = ne.x - sw.x; 61 | 62 | 63 | me.conf.element.style.left = topY + 'px'; 64 | me.conf.element.style.top = leftX + 'px'; 65 | me.conf.element.style.width = w + 'px'; 66 | me.conf.element.style.height = h + 'px'; 67 | me.heatmap.store.get("heatmap").resize(); 68 | 69 | if(this.latlngs.length > 0){ 70 | this.heatmap.clear(); 71 | 72 | var len = this.latlngs.length, 73 | projection = this.getProjection(); 74 | d = { 75 | max: this.heatmap.store.max, 76 | data: [] 77 | }; 78 | 79 | while(len--){ 80 | var latlng = this.latlngs[len].latlng; 81 | if(!currentBounds.contains(latlng)) { continue; } 82 | 83 | // DivPixel is pixel in overlay pixel coordinates... we need 84 | // to transform to screen coordinates so it'll match the canvas 85 | // which is continually repositioned to follow the screen. 86 | var divPixel = projection.fromLatLngToDivPixel(latlng), 87 | screenPixel = new google.maps.Point(divPixel.x - leftX, divPixel.y - topY); 88 | 89 | var roundedPoint = this.pixelTransform(screenPixel); 90 | d.data.push({ 91 | x: roundedPoint.x, 92 | y: roundedPoint.y, 93 | count: this.latlngs[len].c 94 | }); 95 | } 96 | this.heatmap.store.setDataSet(d); 97 | }*/ 98 | } 99 | 100 | HeatmapOverlay.prototype.pixelTransform = function(p){ 101 | var w = this.heatmap.get("width"), 102 | h = this.heatmap.get("height"); 103 | 104 | while(p.x < 0){ 105 | p.x+=w; 106 | } 107 | 108 | while(p.x > w){ 109 | p.x-=w; 110 | } 111 | 112 | while(p.y < 0){ 113 | p.y+=h; 114 | } 115 | 116 | while(p.y > h){ 117 | p.y-=h; 118 | } 119 | 120 | p.x = (p.x >> 0); 121 | p.y = (p.y >> 0); 122 | 123 | return p; 124 | } 125 | 126 | HeatmapOverlay.prototype.setDataSet = function(data){ 127 | 128 | var me = this, 129 | currentBounds = me.map.getBounds(), 130 | mapdata = { 131 | max: data.max, 132 | data: [] 133 | }, 134 | d = data.data, 135 | dlen = d.length, 136 | projection = me.getProjection(), 137 | latlng, point; 138 | 139 | me.latlngs = []; 140 | 141 | while(dlen--){ 142 | latlng = new google.maps.LatLng(d[dlen].lat, d[dlen].lng); 143 | 144 | if(!currentBounds.contains(latlng)) { 145 | continue; 146 | } 147 | 148 | 149 | me.latlngs.push({latlng: latlng, c: d[dlen].count}); 150 | point = me.pixelTransform(projection.fromLatLngToContainerPixel(latlng)); 151 | mapdata.data.push({x: point.x, y: point.y, count: d[dlen].count}); 152 | } 153 | 154 | 155 | var ne = projection.fromLatLngToDivPixel(currentBounds.getNorthEast()), 156 | sw = projection.fromLatLngToDivPixel(currentBounds.getSouthWest()), 157 | topY = ne.y, 158 | leftX = sw.x; 159 | 160 | me.conf.element.style.left = leftX + 'px'; 161 | me.conf.element.style.top = topY + 'px'; 162 | 163 | me.heatmap.clear(); 164 | me.heatmap.store.setDataSet(mapdata); 165 | } 166 | 167 | HeatmapOverlay.prototype.addDataPoint = function(lat, lng, count){ 168 | 169 | var projection = this.getProjection(), 170 | latlng = new google.maps.LatLng(lat, lng), 171 | point = this.pixelTransform(projection.fromLatLngToDivPixel(latlng)); 172 | 173 | this.heatmap.store.addDataPoint(point.x, point.y, count); 174 | this.latlngs.push({ latlng: latlng, c: count }); 175 | } 176 | 177 | HeatmapOverlay.prototype.toggle = function(){ 178 | this.heatmap.toggleDisplay(); 179 | } 180 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | padding: 0; 3 | margin: 0; 4 | 5 | font-family: "Open Sans", sans-serif; 6 | font-weight: 300; 7 | 8 | -webkit-touch-callout: none; 9 | -webkit-user-select: none; 10 | -khtml-user-select: none; 11 | -moz-user-select: none; 12 | -ms-user-select: none; 13 | user-select: none; 14 | } 15 | html, 16 | body { 17 | width: 100%; 18 | height: 100%; 19 | color: black; 20 | background: #95db8f; 21 | } 22 | 23 | #header { 24 | z-index: 99; 25 | margin: 20px; 26 | position: fixed; 27 | } 28 | 29 | h1 { 30 | font-size: 45px; 31 | font-weight: 700; 32 | 33 | line-height: 160%; 34 | margin: 0 25px 0 10px; 35 | 36 | display: block; 37 | float: left; 38 | } 39 | 40 | h1 span { 41 | font-size: 53px; 42 | font-weight: 300; 43 | } 44 | 45 | #animation-control-button { 46 | float: left; 47 | } 48 | 49 | #animation-control-button .play { 50 | border: solid transparent; 51 | margin-left: 5px; 52 | border-width: 25px 0px 25px 39px; 53 | border-left-color: black; 54 | height: 0; 55 | width: 0; 56 | } 57 | 58 | #animation-control-button .pause { 59 | width: 20px; 60 | height: 50px; 61 | border-left: 12px solid black; 62 | border-right: 12px solid black; 63 | } 64 | 65 | #heatmapArea { 66 | height: 100%; 67 | width: 100%; 68 | } 69 | 70 | #progress-bar-background { 71 | position: fixed; 72 | width: 100%; 73 | height: 3px; 74 | left: 0; 75 | bottom: 0; 76 | background: white; 77 | z-index: 98; 78 | } 79 | 80 | #progress-bar-container { 81 | position: fixed; 82 | overflow: visible; 83 | left: 0; 84 | bottom: 0; 85 | z-index: 99; 86 | } 87 | 88 | #progress-bar-pointer { 89 | position: absolute; 90 | right: -160px; 91 | bottom: 0; 92 | right: 0; 93 | 94 | background: #87d69b; 95 | margin-right: -45px; 96 | margin-bottom: 20px; 97 | padding-top: 17px; 98 | text-align: center; 99 | height: 80px; 100 | width: 90px; 101 | line-height: 150%; 102 | 103 | cursor: pointer; 104 | } 105 | 106 | #progress-bar-pointer:after { 107 | position: absolute; 108 | content: " "; 109 | top: 100%; 110 | left: 50%; 111 | border: solid transparent; 112 | border-top-color: #87d69b; 113 | border-width: 15px; 114 | margin-left: -15px; 115 | } 116 | 117 | #progress-bar-pointer .date-prefix { 118 | font-size: 10px; 119 | } 120 | 121 | #progress-bar-pointer .year { 122 | font-size: 13px; 123 | } 124 | 125 | #progress-bar { 126 | position: absolute; 127 | background: #327ccb; 128 | width: 100%; 129 | left: 0; 130 | bottom: 0; 131 | height: 3px; 132 | } 133 | 134 | #progress-bar-instructions { 135 | position: absolute; 136 | right: -160px; 137 | bottom: 80px; 138 | padding: 10px 10px; 139 | width: 80px; 140 | text-align: center; 141 | background: white; 142 | display: none; 143 | } 144 | 145 | #progress-bar-instructions:after { 146 | position: absolute; 147 | content: " "; 148 | top: 20px; 149 | left: 0px; 150 | border: solid transparent; 151 | border-right: none; 152 | border-bottom-color: #fff; 153 | border-width: 15px; 154 | margin-left: -15px; 155 | } 156 | 157 | .button { 158 | text-decoration: none; 159 | color: black; 160 | padding: 10px; 161 | display: block; 162 | background: #87d69b; 163 | -moz-box-shadow: -5px 5px #222; 164 | -webkit-box-shadow: -5px 5px #222; 165 | box-shadow: -5px 5px #222; 166 | } 167 | 168 | .button:hover { 169 | background: #aaebbb; 170 | } 171 | 172 | .button:active { 173 | margin: 3px -3px; 174 | box-shadow: -2px 2px #222; 175 | } 176 | 177 | a.button { 178 | display: inline-block; 179 | font-size: 20px; 180 | padding: 15px; 181 | } 182 | 183 | .button.button-right { 184 | float: right; 185 | display: block; 186 | text-decoration: none; 187 | color: black; 188 | padding: 15px; 189 | display: block; 190 | background: white; 191 | font-size: 16px; 192 | -moz-box-shadow: 5px 5px #222; 193 | -webkit-box-shadow: 5px 5px #222; 194 | box-shadow: 5px 5px #222; 195 | margin-right: 5px; 196 | } 197 | 198 | .button.button-right:hover { 199 | background: #dfd; 200 | } 201 | 202 | .button.button-right:active { 203 | box-shadow: 2px 2px #222; 204 | margin: 3px 2px -3px 3px; 205 | } 206 | 207 | #options-button { 208 | margin-right: 0; 209 | } 210 | #options-button:active { 211 | margin: 3px -3px -3px 3px; 212 | } 213 | #options, 214 | #years { 215 | position: fixed; 216 | top: 60px; 217 | right: 0; 218 | z-index: 97; 219 | text-align: right; 220 | } 221 | 222 | #options-area { 223 | position: fixed; 224 | top: 0; 225 | right: 0; 226 | z-index: 101; 227 | } 228 | 229 | #options-area li { 230 | float: right; 231 | clear: both; 232 | list-style: none; 233 | width: 150px; 234 | padding: 8px 13px; 235 | margin-right: -200px; 236 | transition: margin-right 0.25s ease-in-out; 237 | -moz-transition: margin-right 0.25s ease-in-out; 238 | -webkit-transition: margin-right 0.25s ease-in-out; 239 | font-size: 14px; 240 | background: #bfeadd; 241 | } 242 | #options-area li.title, 243 | #years li.title { 244 | margin-top: 10px; 245 | font-size: 16px; 246 | background: white; 247 | } 248 | 249 | #years li:not(.title) { 250 | padding: 0; 251 | width: 176px; 252 | } 253 | 254 | #years li a { 255 | padding: 8px 15px; 256 | text-decoration: none; 257 | color: black; 258 | width: 150px; 259 | display: block; 260 | line-height: 2.2; 261 | background: #fff; 262 | } 263 | #years li a:hover { 264 | background: #aaebbb; 265 | } 266 | #years li a.current { 267 | background: #dfd; 268 | } 269 | 270 | #options li:nth-child(2) { 271 | background: #a1cdfb; 272 | } 273 | #options li:nth-child(3) { 274 | background: #bfeadd; 275 | } 276 | #options li:nth-child(4) { 277 | background: #c4b0dd; 278 | } 279 | #options li:nth-child(5) { 280 | background: #fae07e; 281 | } 282 | #options li:nth-child(6) { 283 | background: #e5f9a6; 284 | } 285 | 286 | input { 287 | margin: 10px; 288 | } 289 | 290 | .message-box { 291 | background: #fff; 292 | position: fixed; 293 | 294 | z-index: 100; 295 | width: 700px; 296 | top: 50%; 297 | left: 50%; 298 | margin-top: -250px; 299 | margin-left: -350px; 300 | line-height: 160%; 301 | text-align: center; 302 | display: none; 303 | } 304 | 305 | .message-box h2 { 306 | text-align: left; 307 | margin: 40px; 308 | } 309 | 310 | .message-box p { 311 | margin: 20px 40px 30px 40px; 312 | text-align: left; 313 | } 314 | .message-box .footer { 315 | margin: 40px 40px 0 40px; 316 | text-align: center; 317 | text-weight: 800; 318 | padding-bottom: 2em; 319 | } 320 | 321 | .message-box .footer a { 322 | text-decoration: none; 323 | font-weight: 800; 324 | color: #333; 325 | } 326 | 327 | .message-box .footer a:hover { 328 | color: #95db8f; 329 | } 330 | 331 | a.button { 332 | display: inline-block; 333 | font-size: 20px; 334 | padding: 15px; 335 | } 336 | 337 | .mobile-only-content { 338 | display: none; 339 | } 340 | 341 | #mobile-icon { 342 | width: 40px; 343 | height: 60px; 344 | background-color: #000; 345 | 346 | border-radius: 4px; 347 | -webkit-border-radius: 4px; 348 | -moz-border-radius: 4px; 349 | 350 | position: relative; 351 | float: left; 352 | margin: 0px 20px; 353 | margin-top: -10px; 354 | } 355 | 356 | #mobile-icon div { 357 | background: #fff; 358 | position: absolute; 359 | } 360 | 361 | #mobile-icon .line { 362 | width: 14px; 363 | height: 1px; 364 | top: 6px; 365 | left: 13px; 366 | } 367 | 368 | #mobile-icon .screen { 369 | width: 34px; 370 | height: 37px; 371 | top: 10px; 372 | left: 3px; 373 | } 374 | 375 | #mobile-icon .dot { 376 | width: 4px; 377 | height: 4px; 378 | bottom: 5px; 379 | left: 18px; 380 | 381 | border-radius: 4px; 382 | -webkit-border-radius: 4px; 383 | -moz-border-radius: 4px; 384 | } 385 | 386 | #legend { 387 | height: 250px; 388 | width: 60px; 389 | position: fixed; 390 | left: 0; 391 | bottom: 125px; 392 | margin-left: 25px; 393 | overflow: visible; 394 | } 395 | 396 | #numbers, 397 | #colors { 398 | position: absolute; 399 | top: 0; 400 | left: 0; 401 | display: table; 402 | height: 100%; 403 | width: 100%; 404 | z-index: 97; 405 | } 406 | #numbers { 407 | z-index: 98; 408 | } 409 | 410 | #numbers div, 411 | #colors div { 412 | display: table-row; 413 | } 414 | 415 | #numbers div a, 416 | #colors div span { 417 | width: 100%; 418 | display: table-cell; 419 | text-align: center; 420 | vertical-align: middle; 421 | cursor: default; 422 | } 423 | #colors div:nth-child(1) span { 424 | background: #fcb7a7; 425 | } 426 | #colors div:nth-child(2) span { 427 | background: #f8f087; 428 | } 429 | #colors div:nth-child(3) span { 430 | background: #b7e3c0; 431 | } 432 | #colors div:nth-child(4) span { 433 | background: #b8d0dd; 434 | } 435 | 436 | @media (max-width: 1000px) { 437 | h1 { 438 | font-size: 30px; 439 | } 440 | 441 | h1 span { 442 | font-size: 45px; 443 | } 444 | 445 | * { 446 | font-size: 14px; 447 | } 448 | 449 | .message-box { 450 | width: 100%; 451 | height: 100%; 452 | position: absolute; 453 | padding: 0; 454 | top: 0; 455 | left: 0; 456 | margin: 0; 457 | overflow: auto; 458 | } 459 | 460 | .message-box h2 { 461 | font-size: 18px; 462 | } 463 | 464 | #legend { 465 | height: 180px; 466 | width: 50px; 467 | top: 90px; 468 | margin-left: 25px; 469 | } 470 | } 471 | 472 | @media (max-width: 1000px) { 473 | h1 { 474 | font-size: 20px; 475 | } 476 | 477 | h1 span { 478 | font-size: 35px; 479 | } 480 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 |
14 |

About the Halifax Crime Heatmap

15 |

16 | Halifax Crime Maps is an animated heatmap of crimes in Halifax powered 17 | by OpenDataHalifax. Watch 18 | each week as new data is added and the animation grows.

19 | Our heatmap shows the density of crime in Halifax. You can apply 20 | filters, drag the progress bar to specific dates, or pause the animation 21 | and see the individual crime markers. 22 |

23 | This requires some heavy duty HTML5 features and works best in Google 24 | Chrome. We disable certain features on mobile devices and other 25 | browsers. 26 |

27 | 28 |
29 |
30 |
31 |
32 |
33 |
34 | We can only pack so much awesome into your mobile device, visit on a 35 | computer for the full effect. 36 |
37 | 38 | 41 |
42 | 43 | 49 | 50 |
51 | options 52 | years 53 | about 54 | 55 | 58 | 59 | 93 |
94 | 95 |
96 | 97 |
98 |
99 |
4
100 |
3
101 |
2
102 |
1
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 | 112 |
113 |
114 |
115 |
116 |
drag me
to skip days
117 |
118 |
119 |
120 | 121 | 122 | 123 | 124 | 297 | 298 | 299 | 300 | 301 | -------------------------------------------------------------------------------- /js/crimeAnimator.js: -------------------------------------------------------------------------------- 1 | var CrimeAnimator = function (data, heatmap, map) { 2 | this.map = map; 3 | this.data = data; 4 | this.heatmap = heatmap; 5 | this.dataLength = data.length - 1; 6 | 7 | //create marker array to track displayed markers and the info window that displays when they are clicked 8 | this.markers = []; 9 | this.showMarkers = true; 10 | this.markerInfoWindow = new google.maps.InfoWindow(); 11 | 12 | //figure out the date range 13 | this.dateRange = { start: data[0][3], end: data[data.length - 1][3] }; 14 | this.dayRange = Math.ceil( 15 | (this.dateRange.end - this.dateRange.start) / DAY_IN_MILLI 16 | ); 17 | 18 | this.isPaused = false; 19 | 20 | //calculate the number of days that elapse for each animation frame 21 | this.animationTimeMultiplier = 500000 * (this.dayRange / 365); 22 | 23 | //calculate how long crimes should be displayed for based on our range 24 | this.visibleLifespan = Math.ceil(this.dayRange / 10); 25 | if (this.visibleLifespan < 10) this.visibleLifespan = 10; 26 | else if (this.visibleLifespan > 30) this.visibleLifespan = 30; 27 | this.visibleLifespan = this.visibleLifespan * DAY_IN_MILLI; 28 | 29 | this.lowIndex = 0; //holds the data index of the oldest crime we are displaying 30 | this.highIndex = 0; //holds the data index of the newest crime we are displaying 31 | 32 | this.filters = []; 33 | 34 | this.setupProgressBar(); 35 | 36 | var self = this; 37 | var $animationControlButton = $("#animation-control-button"); 38 | $animationControlButton.click(this.togglePause.bind(this)); 39 | 40 | //handle the filter click events 41 | $("#options input:not(#options-markers)").on( 42 | "change", 43 | this.changeFilters.bind(this) 44 | ); 45 | $("#options-markers").on("change", this.toggleCrimeMarkers.bind(this)); 46 | 47 | //hookup spacebar pauser/resume 48 | $(window).keypress(this.togglePause.bind(this)); 49 | 50 | this.$animationControl = $animationControlButton.children("div"); 51 | 52 | //if the animation is paused then redraw the current frame on drag end 53 | google.maps.event.addListener(map, "dragend", function () { 54 | if (self.isPaused) self.drawFrame(self.currentDate); 55 | }); 56 | 57 | google.maps.event.addListener(map, "zoom_changed", function () { 58 | var radiusMultiplier, 59 | zoom = map.getZoom(); 60 | if (zoom > 10) radiusMultiplier = 2.6; 61 | else if (zoom < 9) radiusMultiplier = 1; 62 | else radiusMultiplier = 1.5; 63 | 64 | heatmap.heatmap.set("radius", zoom * radiusMultiplier); 65 | 66 | if (self.isPaused) self.drawFrame(self.currentDate); 67 | }); 68 | }; 69 | 70 | CrimeAnimator.prototype.setupProgressBar = function setupProgressBar() { 71 | //setup progress bar slider events 72 | this.$progressBar = $("#progress-bar-container"); 73 | var $progressBarPointer = $("#progress-bar-pointer"); 74 | var $document = $(document); 75 | 76 | this.progressBarStartPosition = Math.ceil($document.width() * 0.05); 77 | this.progressBarEndPosition = 78 | $document.width() - this.progressBarStartPosition; 79 | 80 | this.$progressBar.css("width", this.progressBarStartPosition + "px"); 81 | 82 | this.totalTimeRange = this.dateRange.end - this.dateRange.start; 83 | 84 | this.totalPixelRange = 85 | this.progressBarEndPosition - this.progressBarStartPosition; 86 | 87 | var self = this; 88 | var stopPointerDrag = function (e) { 89 | self.updateCurrentDateToProgressPosition(e.pageX); 90 | 91 | //draw the heatmap frame for our current position 92 | self.lowIndex = 0; 93 | self.highIndex = 0; 94 | self.drawFrame(self.currentDate); 95 | 96 | self.removeCrimeMarkers(); 97 | if (self.showMarkers) self.addCrimeMarkers(); 98 | 99 | $document.off("mousemove ontouchmove"); 100 | e.preventDefault(); 101 | }; 102 | 103 | //keep track of the top of the progres bar so that we can stop dragging when the mouse leaves the top 104 | var pointerOffsetTop = $progressBarPointer.offset().top; 105 | $(window).resize(function () { 106 | pointerOffsetTop = $progressBarPointer.offset().top; 107 | }); 108 | 109 | //setup dragability of progress bar pointer 110 | $progressBarPointer.on("mousedown ontouchstart", function (e) { 111 | self.pause.apply(self); 112 | 113 | $document.on("mousemove ontouchmove", function (e) { 114 | if (e.pageY < pointerOffsetTop) { 115 | stopPointerDrag(e); 116 | return; 117 | } 118 | 119 | //impose start/end limits on progres bar 120 | var x = e.pageX; 121 | if (x > self.progressBarEndPosition) x = self.progressBarEndPosition; 122 | else if (x < self.progressBarStartPosition) 123 | x = self.progressBarStartPosition; 124 | 125 | self.$progressBar.css("width", x + "px"); 126 | 127 | //update progress bar text 128 | self.updateCurrentDateToProgressPosition(x); 129 | self.updateProgressBarText(); 130 | 131 | self.removeCrimeMarkers(); 132 | 133 | self.drawFrame(self.currentDate); 134 | 135 | e.preventDefault(); 136 | }); 137 | 138 | e.preventDefault(); 139 | }); 140 | 141 | //stop dragging 142 | $progressBarPointer.on("mouseup ontouchend", stopPointerDrag); 143 | 144 | this.$progressBarPointer = $progressBarPointer; 145 | }; 146 | 147 | var monthNames = [ 148 | "January", 149 | "February", 150 | "March", 151 | "April", 152 | "May", 153 | "June", 154 | "July", 155 | "August", 156 | "September", 157 | "October", 158 | "November", 159 | "December", 160 | ]; 161 | CrimeAnimator.prototype.updateProgressBar = function updateProgressBar() { 162 | var x = 163 | this.progressBarStartPosition + 164 | ((this.currentDate - this.dateRange.start) / this.totalTimeRange) * 165 | this.totalPixelRange; 166 | if (x > this.progressBarEndPosition) x = this.progressBarEndPosition; 167 | 168 | this.$progressBar.css("width", x + "px"); 169 | 170 | this.updateProgressBarText(); 171 | }; 172 | 173 | CrimeAnimator.prototype.updateCurrentDateToProgressPosition = 174 | function updateCurrentDateToProgressPosition(progressPosition) { 175 | //calc the current position of pointer and translate that into an animation position date 176 | var progressBarPercentComplete = 177 | (progressPosition - this.progressBarStartPosition) / this.totalPixelRange; 178 | this.currentDate = 179 | this.dateRange.start + 180 | (this.dateRange.end - this.dateRange.start) * progressBarPercentComplete; 181 | }; 182 | 183 | CrimeAnimator.prototype.updateProgressBarText = 184 | function updateProgressBarText() { 185 | var now = new Date(this.currentDate); 186 | this.$progressBarPointer.html( 187 | monthNames[now.getMonth()] + 188 | "
" + 189 | now.getDate() + 190 | "" + 191 | getNumberPostfix(now.getDate()) + 192 | "
" + 193 | "- " + 194 | now.getFullYear() + 195 | " -
" 196 | ); 197 | }; 198 | 199 | var numberEndings = ["", "st", "nd", "rd", "th"]; 200 | function getNumberPostfix(number) { 201 | return numberEndings[ 202 | number >= numberEndings.length ? numberEndings.length - 1 : number 203 | ]; 204 | } 205 | 206 | CrimeAnimator.prototype.applyFilters = function setData(filters) { 207 | this.filters = filters; 208 | }; 209 | 210 | (CrimeAnimator.prototype.updateIndexPositions = function updateIndexPositions( 211 | visibleThreshold, 212 | nextDate 213 | ) { 214 | this.lowIndex = this.getNewIndexPosition(this.lowIndex, visibleThreshold, 0); 215 | 216 | if (this.highIndex < this.lowIndex) this.highIndex = this.lowIndex; 217 | 218 | this.highIndex = this.getNewIndexPosition( 219 | this.highIndex, 220 | nextDate, 221 | this.lowIndex 222 | ); 223 | }), 224 | /** 225 | adjust index position to match the comparitor date and the current data 226 | */ 227 | (CrimeAnimator.prototype.getNewIndexPosition = function getNewIndexPosition( 228 | index, 229 | comparitorDate, 230 | indexMinimum 231 | ) { 232 | //expand the index till it reaches the compairitor date 233 | while (index < this.dataLength) { 234 | if (this.data[index][3] >= comparitorDate) break; 235 | 236 | index += 1; 237 | } 238 | 239 | //contract the index till it reaches the compairitor date 240 | while (index > indexMinimum) { 241 | if (this.data[index][3] < comparitorDate) break; 242 | 243 | index -= 1; 244 | } 245 | 246 | return index; 247 | }); 248 | 249 | (CrimeAnimator.prototype.drawFrame = function drawFrame(nextDate) { 250 | var visibleThreshold = this.currentDate - this.visibleLifespan; 251 | this.updateIndexPositions(visibleThreshold, nextDate); 252 | 253 | //if no removals to make then just add the crime points that are between the currentDate and the next Date and aren't filtered 254 | var currentData = this.getCurrentHeatmapData(visibleThreshold); 255 | this.heatmap.setDataSet(currentData); 256 | }), 257 | (CrimeAnimator.prototype.animate = function animate() { 258 | var now = new Date().getTime(); 259 | 260 | if (!this.isPaused) { 261 | var nextDate = 262 | this.currentDate + 263 | (now - this.lastDrawTime) * this.animationTimeMultiplier; 264 | 265 | this.drawFrame(nextDate); 266 | 267 | //increment currentDate to next date 268 | this.currentDate = nextDate; 269 | 270 | //update our current progress to match the current date 271 | this.updateProgressBar(); 272 | } 273 | 274 | this.lastDrawTime = now; 275 | 276 | if (this.highIndex < this.dataLength) 277 | window.requestAnimationFrame(this.animate.bind(this)); 278 | else { 279 | //we are done 280 | this.isAnimating = false; 281 | this.pause(); 282 | } 283 | }); 284 | 285 | CrimeAnimator.prototype.start = function start() { 286 | //set the current date to the first day if we are at the end of the animation 287 | if (this.highIndex >= this.dataLength || !this.currentDate) 288 | this.currentDate = this.dateRange.start; 289 | 290 | //reset our indexes 291 | this.lowIndex = 0; 292 | this.highIndex = 0; 293 | 294 | this.lastDrawTime = new Date().getTime(); 295 | 296 | this.isAnimating = true; 297 | 298 | //start the animation 299 | this.animate(); 300 | }; 301 | 302 | CrimeAnimator.prototype.pause = function pause() { 303 | this.$animationControl.removeClass("pause"); 304 | this.$animationControl.addClass("play"); 305 | 306 | this.isPaused = true; 307 | 308 | //draw markers 309 | if (this.showMarkers) this.addCrimeMarkers(); 310 | }; 311 | 312 | CrimeAnimator.prototype.resume = function resume() { 313 | this.removeCrimeMarkers(); 314 | 315 | this.$animationControl.removeClass("play"); 316 | this.$animationControl.addClass("pause"); 317 | 318 | this.isPaused = false; 319 | 320 | //if we are at the end of the animation then restart it 321 | if (!this.isAnimating) this.start(); 322 | }; 323 | 324 | CrimeAnimator.prototype.togglePause = function togglePause() { 325 | if (this.isPaused) this.resume(); 326 | else this.pause(); 327 | }; 328 | 329 | CrimeAnimator.prototype.changeFilters = function changeFilters(e) { 330 | var checkbox = e.target; 331 | 332 | var filterNumber = Number(checkbox.name); 333 | if (!checkbox.checked) { 334 | this.filters.push(filterNumber); 335 | } else { 336 | var index = this.filters.indexOf(filterNumber); 337 | if (index > -1) this.filters.splice(index, 1); 338 | } 339 | 340 | if (this.isPaused || !this.isAnimating) { 341 | this.drawFrame(this.currentDate); 342 | this.removeCrimeMarkers(); 343 | if (this.showMarkers) this.addCrimeMarkers(); 344 | } 345 | }; 346 | 347 | CrimeAnimator.prototype.toggleCrimeMarkers = function toggleCrimeMarkers() { 348 | this.showMarkers = !this.showMarkers; 349 | 350 | if (!this.showMarkers) this.removeCrimeMarkers(); 351 | else if (this.isPaused || !this.isAnimating) this.addCrimeMarkers(); 352 | }; 353 | 354 | var crimeTypes = [ 355 | "Theft from Auto", 356 | "Auto Theft", 357 | "Break & Enter", 358 | "Assault", 359 | "Robbery", 360 | ]; 361 | var crimeColors = ["pink", "yellow", "red", "blue", "green"]; 362 | CrimeAnimator.prototype.addCrimeMarkers = function addCrimeMarkers() { 363 | var currentData = this.data.slice(this.lowIndex, this.highIndex + 1); 364 | var self = this; 365 | 366 | for (var i = currentData.length - 1; i >= 0; i--) { 367 | var crime = currentData[i]; 368 | var crimeType = crime[2]; 369 | 370 | if (this.filters.indexOf(crimeType) >= 0) continue; 371 | 372 | var marker = new google.maps.Marker({ 373 | position: new google.maps.LatLng(crime[0], crime[1]), 374 | map: this.map, 375 | title: crimeTypes[crimeType], 376 | icon: { 377 | url: 378 | "http://maps.google.com/mapfiles/ms/icons/" + 379 | crimeColors[crimeType] + 380 | "-dot.png", 381 | }, 382 | }); 383 | 384 | this.markers.push(marker); 385 | 386 | //setup the markers onclick event to display the popup window 387 | /*function setupMarkerInfoWindow(marker, crime) { 388 | google.maps.event.addListener(marker, 'click', function() { 389 | self.markerInfoWindow.content = 'type: ' + crime[2] + '
date: ' + Date.parse(year + '/' + (crime[3]+1) + '/' + crime[4]); 390 | self.markerInfoWindow.open(self.map, marker); 391 | }); 392 | }(marker, crime);*/ 393 | } 394 | }; 395 | 396 | CrimeAnimator.prototype.removeCrimeMarkers = function removeCrimeMarkers() { 397 | for (var i = this.markers.length - 1; i >= 0; i--) { 398 | this.markers.pop().setMap(null); 399 | } 400 | }; 401 | 402 | CrimeAnimator.prototype.getCurrentHeatmapData = function getCurrentHeatmapData( 403 | visibleThreshold 404 | ) { 405 | var currentData = this.data.slice(this.lowIndex, this.highIndex + 1); 406 | 407 | var crime, 408 | type, 409 | count, 410 | timeFromHighIndex, 411 | heatmapData = []; 412 | var currentTimeRange = this.currentDate - visibleThreshold; 413 | 414 | for (var i = currentData.length - 1; i >= 0; i--) { 415 | crime = currentData[i]; 416 | type = crime[2]; 417 | 418 | if (this.filters.indexOf(type) >= 0) continue; 419 | 420 | timeFromHighIndex = this.currentDate - crime[3]; 421 | var x = timeFromHighIndex / currentTimeRange; 422 | count = Math.abs(4 * (x - Math.pow(x, 2))); // exponential curve for fade in/out 423 | 424 | heatmapData.push({ lat: crime[0], lng: crime[1], count: count }); 425 | } 426 | 427 | return { max: 3, data: heatmapData }; 428 | }; 429 | -------------------------------------------------------------------------------- /js/h-map.js: -------------------------------------------------------------------------------- 1 | /* 2 | * heatmap.js 1.0 - JavaScript Heatmap Library 3 | * 4 | * Copyright (c) 2011, Patrick Wied (http://www.patrick-wied.at) 5 | * Dual-licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) 6 | * and the Beerware (http://en.wikipedia.org/wiki/Beerware) license. 7 | */ 8 | 9 | (function(w){ 10 | // the heatmapFactory creates heatmap instances 11 | var heatmapFactory = (function(){ 12 | 13 | // store object constructor 14 | // a heatmap contains a store 15 | // the store has to know about the heatmap in order to trigger heatmap updates when datapoints get added 16 | var store = function store(hmap){ 17 | 18 | var _ = { 19 | // data is a two dimensional array 20 | // a datapoint gets saved as data[point-x-value][point-y-value] 21 | // the value at [point-x-value][point-y-value] is the occurrence of the datapoint 22 | data: [], 23 | // tight coupling of the heatmap object 24 | heatmap: hmap 25 | }; 26 | // the max occurrence - the heatmaps radial gradient alpha transition is based on it 27 | this.max = 1; 28 | 29 | this.get = function(key){ 30 | return _[key]; 31 | }; 32 | this.set = function(key, value){ 33 | _[key] = value; 34 | }; 35 | } 36 | 37 | store.prototype = { 38 | // function for adding datapoints to the store 39 | // datapoints are usually defined by x and y but could also contain a third parameter which represents the occurrence 40 | addDataPoint: function(x, y){ 41 | if(x < 0 || y < 0) 42 | return; 43 | 44 | var me = this, 45 | heatmap = me.get("heatmap"), 46 | data = me.get("data"); 47 | 48 | if(!data[x]) 49 | data[x] = []; 50 | 51 | if(!data[x][y]) 52 | data[x][y] = 0; 53 | 54 | // if count parameter is set increment by count otherwise by 1 55 | data[x][y]+=(arguments.length<3)?1:arguments[2]; 56 | 57 | me.set("data", data); 58 | // do we have a new maximum? 59 | if(me.max < data[x][y]){ 60 | // max changed, we need to redraw all existing(lower) datapoints 61 | heatmap.get("actx").clearRect(0,0,heatmap.get("width"),heatmap.get("height")); 62 | me.setDataSet({ max: data[x][y], data: data }, true); 63 | return; 64 | } 65 | heatmap.drawAlpha(x, y, data[x][y], true); 66 | }, 67 | setDataSet: function(obj, internal){ 68 | var me = this, 69 | heatmap = me.get("heatmap"), 70 | data = [], 71 | d = obj.data, 72 | dlen = d.length; 73 | // clear the heatmap before the data set gets drawn 74 | heatmap.clear(); 75 | this.max = obj.max; 76 | // if a legend is set, update it 77 | heatmap.get("legend") && heatmap.get("legend").update(obj.max); 78 | 79 | if(internal != null && internal){ 80 | for(var one in d){ 81 | // jump over undefined indexes 82 | if(one === undefined) 83 | continue; 84 | for(var two in d[one]){ 85 | if(two === undefined) 86 | continue; 87 | // if both indexes are defined, push the values into the array 88 | heatmap.drawAlpha(one, two, d[one][two], false); 89 | } 90 | } 91 | }else{ 92 | while(dlen--){ 93 | var point = d[dlen]; 94 | heatmap.drawAlpha(point.x, point.y, point.count, false); 95 | if(!data[point.x]) 96 | data[point.x] = []; 97 | 98 | if(!data[point.x][point.y]) 99 | data[point.x][point.y] = 0; 100 | 101 | data[point.x][point.y] = point.count; 102 | } 103 | } 104 | heatmap.colorize(); 105 | this.set("data", d); 106 | }, 107 | exportDataSet: function(){ 108 | var me = this, 109 | data = me.get("data"), 110 | exportData = []; 111 | 112 | for(var one in data){ 113 | // jump over undefined indexes 114 | if(one === undefined) 115 | continue; 116 | for(var two in data[one]){ 117 | if(two === undefined) 118 | continue; 119 | // if both indexes are defined, push the values into the array 120 | exportData.push({x: parseInt(one, 10), y: parseInt(two, 10), count: data[one][two]}); 121 | } 122 | } 123 | 124 | return { max: me.max, data: exportData }; 125 | }, 126 | generateRandomDataSet: function(points){ 127 | var heatmap = this.get("heatmap"), 128 | w = heatmap.get("width"), 129 | h = heatmap.get("height"); 130 | var randomset = {}, 131 | max = Math.floor(Math.random()*1000+1); 132 | randomset.max = max; 133 | var data = []; 134 | while(points--){ 135 | data.push({x: Math.floor(Math.random()*w+1), y: Math.floor(Math.random()*h+1), count: Math.floor(Math.random()*max+1)}); 136 | } 137 | randomset.data = data; 138 | this.setDataSet(randomset); 139 | } 140 | }; 141 | 142 | var legend = function legend(config){ 143 | this.config = config; 144 | 145 | var _ = { 146 | element: null, 147 | labelsEl: null, 148 | gradientCfg: null, 149 | ctx: null 150 | }; 151 | this.get = function(key){ 152 | return _[key]; 153 | }; 154 | this.set = function(key, value){ 155 | _[key] = value; 156 | }; 157 | this.init(); 158 | }; 159 | legend.prototype = { 160 | init: function(){ 161 | var me = this, 162 | config = me.config, 163 | title = config.title || "Legend", 164 | position = config.position, 165 | offset = config.offset || 10, 166 | gconfig = config.gradient, 167 | labelsEl = document.createElement("ul"), 168 | labelsHtml = "", 169 | grad, element, gradient, positionCss = ""; 170 | 171 | me.processGradientObject(); 172 | 173 | // Positioning 174 | 175 | // top or bottom 176 | if(position.indexOf('t') > -1){ 177 | positionCss += 'top:'+offset+'px;'; 178 | }else{ 179 | positionCss += 'bottom:'+offset+'px;'; 180 | } 181 | 182 | // left or right 183 | if(position.indexOf('l') > -1){ 184 | positionCss += 'left:'+offset+'px;'; 185 | }else{ 186 | positionCss += 'right:'+offset+'px;'; 187 | } 188 | 189 | element = document.createElement("div"); 190 | element.style.cssText = "border-radius:5px;position:absolute;"+positionCss+"font-family:Helvetica; width:256px;z-index:95; background:rgba(255,255,255,1);padding:10px;border:1px solid black;margin:0;"; 191 | element.innerHTML = "

"+title+"

"; 192 | // create gradient in canvas 193 | labelsEl.style.cssText = "position:relative;font-size:12px;display:block;list-style:none;list-style-type:none;margin:0;height:15px;"; 194 | 195 | 196 | // create gradient element 197 | gradient = document.createElement("div"); 198 | gradient.style.cssText = ["position:relative;display:block;width:256px;height:15px;border-bottom:1px solid black; background-image:url(",me.createGradientImage(),");"].join(""); 199 | 200 | element.appendChild(labelsEl); 201 | element.appendChild(gradient); 202 | 203 | me.set("element", element); 204 | me.set("labelsEl", labelsEl); 205 | 206 | me.update(1); 207 | }, 208 | processGradientObject: function(){ 209 | // create array and sort it 210 | var me = this, 211 | gradientConfig = this.config.gradient, 212 | gradientArr = []; 213 | 214 | for(var key in gradientConfig){ 215 | if(gradientConfig.hasOwnProperty(key)){ 216 | gradientArr.push({ stop: key, value: gradientConfig[key] }); 217 | } 218 | } 219 | gradientArr.sort(function(a, b){ 220 | return (a.stop - b.stop); 221 | }); 222 | gradientArr.unshift({ stop: 0, value: 'rgba(0,0,0,0)' }); 223 | 224 | me.set("gradientArr", gradientArr); 225 | }, 226 | createGradientImage: function(){ 227 | var me = this, 228 | gradArr = me.get("gradientArr"), 229 | length = gradArr.length, 230 | canvas = document.createElement("canvas"), 231 | ctx = canvas.getContext("2d"), 232 | grad; 233 | // the gradient in the legend including the ticks will be 256x15px 234 | canvas.width = "256"; 235 | canvas.height = "15"; 236 | 237 | grad = ctx.createLinearGradient(0,5,256,10); 238 | 239 | for(var i = 0; i < length; i++){ 240 | grad.addColorStop(1/(length-1) * i, gradArr[i].value); 241 | } 242 | 243 | ctx.fillStyle = grad; 244 | ctx.fillRect(0,5,256,10); 245 | ctx.strokeStyle = "black"; 246 | ctx.beginPath(); 247 | 248 | for(var i = 0; i < length; i++){ 249 | ctx.moveTo(((1/(length-1)*i*256) >> 0)+.5, 0); 250 | ctx.lineTo(((1/(length-1)*i*256) >> 0)+.5, (i==0)?15:5); 251 | } 252 | ctx.moveTo(255.5, 0); 253 | ctx.lineTo(255.5, 15); 254 | ctx.moveTo(255.5, 4.5); 255 | ctx.lineTo(0, 4.5); 256 | 257 | ctx.stroke(); 258 | 259 | // we re-use the context for measuring the legends label widths 260 | me.set("ctx", ctx); 261 | 262 | return canvas.toDataURL(); 263 | }, 264 | getElement: function(){ 265 | return this.get("element"); 266 | }, 267 | update: function(max){ 268 | var me = this, 269 | gradient = me.get("gradientArr"), 270 | ctx = me.get("ctx"), 271 | labels = me.get("labelsEl"), 272 | labelText, labelsHtml = "", offset; 273 | 274 | for(var i = 0; i < gradient.length; i++){ 275 | 276 | labelText = max*gradient[i].stop >> 0; 277 | offset = (ctx.measureText(labelText).width/2) >> 0; 278 | 279 | if(i == 0){ 280 | offset = 0; 281 | } 282 | if(i == gradient.length-1){ 283 | offset *= 2; 284 | } 285 | labelsHtml += '
  • '+labelText+'
  • '; 286 | } 287 | labels.innerHTML = labelsHtml; 288 | } 289 | }; 290 | 291 | // heatmap object constructor 292 | var heatmap = function heatmap(config){ 293 | // private variables 294 | var _ = { 295 | radius : 40, 296 | element : {}, 297 | canvas : {}, 298 | acanvas: {}, 299 | ctx : {}, 300 | actx : {}, 301 | legend: null, 302 | visible : true, 303 | width : 0, 304 | height : 0, 305 | max : false, 306 | gradient : false, 307 | opacity: 180, 308 | premultiplyAlpha: false, 309 | bounds: { 310 | l: 1000, 311 | r: 0, 312 | t: 1000, 313 | b: 0 314 | }, 315 | debug: false 316 | }; 317 | // heatmap store containing the datapoints and information about the maximum 318 | // accessible via instance.store 319 | this.store = new store(this); 320 | 321 | this.get = function(key){ 322 | return _[key]; 323 | }; 324 | this.set = function(key, value){ 325 | _[key] = value; 326 | }; 327 | // configure the heatmap when an instance gets created 328 | this.configure(config); 329 | // and initialize it 330 | this.init(); 331 | }; 332 | 333 | // public functions 334 | heatmap.prototype = { 335 | configure: function(config){ 336 | var me = this, 337 | rout, rin; 338 | 339 | me.set("radius", config["radius"] || 40); 340 | me.set("element", (config.element instanceof Object)?config.element:document.getElementById(config.element)); 341 | me.set("visible", (config.visible != null)?config.visible:true); 342 | me.set("max", config.max || false); 343 | me.set("gradient", config.gradient || { 0.45: "rgb(0,0,255)", 0.55: "rgb(0,255,255)", 0.65: "rgb(0,255,0)", 0.95: "yellow", 1.0: "rgb(255,0,0)"}); // default is the common blue to red gradient 344 | me.set("opacity", parseInt(255/(100/config.opacity), 10) || 180); 345 | me.set("width", config.width || 0); 346 | me.set("height", config.height || 0); 347 | me.set("debug", config.debug); 348 | 349 | if(config.legend){ 350 | var legendCfg = config.legend; 351 | legendCfg.gradient = me.get("gradient"); 352 | me.set("legend", new legend(legendCfg)); 353 | } 354 | 355 | }, 356 | resize: function () { 357 | var me = this, 358 | element = me.get("element"), 359 | canvas = me.get("canvas"), 360 | acanvas = me.get("acanvas"); 361 | canvas.width = acanvas.width = me.get("width") || element.style.width.replace(/px/, "") || me.getWidth(element); 362 | this.set("width", canvas.width); 363 | canvas.height = acanvas.height = me.get("height") || element.style.height.replace(/px/, "") || me.getHeight(element); 364 | this.set("height", canvas.height); 365 | }, 366 | 367 | init: function(){ 368 | var me = this, 369 | canvas = document.createElement("canvas"), 370 | acanvas = document.createElement("canvas"), 371 | ctx = canvas.getContext("2d"), 372 | actx = acanvas.getContext("2d"), 373 | element = me.get("element"); 374 | 375 | 376 | me.initColorPalette(); 377 | 378 | me.set("canvas", canvas); 379 | me.set("ctx", ctx); 380 | me.set("acanvas", acanvas); 381 | me.set("actx", actx); 382 | 383 | me.resize(); 384 | canvas.style.cssText = acanvas.style.cssText = "position:absolute;top:0;left:0;z-index:95;"; 385 | 386 | if(!me.get("visible")) 387 | canvas.style.display = "none"; 388 | 389 | element.appendChild(canvas); 390 | if(me.get("legend")){ 391 | element.appendChild(me.get("legend").getElement()); 392 | } 393 | 394 | // debugging purposes only 395 | if(me.get("debug")) 396 | document.body.appendChild(acanvas); 397 | 398 | actx.shadowOffsetX = 15000; 399 | actx.shadowOffsetY = 15000; 400 | actx.shadowBlur = 15; 401 | }, 402 | initColorPalette: function(){ 403 | 404 | var me = this, 405 | canvas = document.createElement("canvas"), 406 | gradient = me.get("gradient"), 407 | ctx, grad, testData; 408 | 409 | canvas.width = "1"; 410 | canvas.height = "256"; 411 | ctx = canvas.getContext("2d"); 412 | grad = ctx.createLinearGradient(0,0,1,256); 413 | 414 | // Test how the browser renders alpha by setting a partially transparent pixel 415 | // and reading the result. A good browser will return a value reasonably close 416 | // to what was set. Some browsers (e.g. on Android) will return a ridiculously wrong value. 417 | testData = ctx.getImageData(0,0,1,1); 418 | testData.data[0] = testData.data[3] = 64; // 25% red & alpha 419 | testData.data[1] = testData.data[2] = 0; // 0% blue & green 420 | ctx.putImageData(testData, 0, 0); 421 | testData = ctx.getImageData(0,0,1,1); 422 | me.set("premultiplyAlpha", (testData.data[0] < 60 || testData.data[0] > 70)); 423 | 424 | for(var x in gradient){ 425 | grad.addColorStop(x, gradient[x]); 426 | } 427 | 428 | ctx.fillStyle = grad; 429 | ctx.fillRect(0,0,1,256); 430 | 431 | me.set("gradient", ctx.getImageData(0,0,1,256).data); 432 | }, 433 | getWidth: function(element){ 434 | var width = element.offsetWidth; 435 | if(element.style.paddingLeft){ 436 | width+=element.style.paddingLeft; 437 | } 438 | if(element.style.paddingRight){ 439 | width+=element.style.paddingRight; 440 | } 441 | 442 | return width; 443 | }, 444 | getHeight: function(element){ 445 | var height = element.offsetHeight; 446 | if(element.style.paddingTop){ 447 | height+=element.style.paddingTop; 448 | } 449 | if(element.style.paddingBottom){ 450 | height+=element.style.paddingBottom; 451 | } 452 | 453 | return height; 454 | }, 455 | colorize: function(x, y){ 456 | // get the private variables 457 | var me = this, 458 | width = me.get("width"), 459 | radius = me.get("radius"), 460 | height = me.get("height"), 461 | actx = me.get("actx"), 462 | ctx = me.get("ctx"), 463 | x2 = radius * 3, 464 | premultiplyAlpha = me.get("premultiplyAlpha"), 465 | palette = me.get("gradient"), 466 | opacity = me.get("opacity"), 467 | bounds = me.get("bounds"), 468 | left, top, bottom, right, 469 | image, imageData, length, alpha, offset, finalAlpha; 470 | 471 | if(x != null && y != null){ 472 | if(x+x2>width){ 473 | x=width-x2; 474 | } 475 | if(x<0){ 476 | x=0; 477 | } 478 | if(y<0){ 479 | y=0; 480 | } 481 | if(y+x2>height){ 482 | y=height-x2; 483 | } 484 | left = x; 485 | top = y; 486 | right = x + x2; 487 | bottom = y + x2; 488 | 489 | }else{ 490 | if(bounds['l'] < 0){ 491 | left = 0; 492 | }else{ 493 | left = bounds['l']; 494 | } 495 | if(bounds['r'] > width){ 496 | right = width; 497 | }else{ 498 | right = bounds['r']; 499 | } 500 | if(bounds['t'] < 0){ 501 | top = 0; 502 | }else{ 503 | top = bounds['t']; 504 | } 505 | if(bounds['b'] > height){ 506 | bottom = height; 507 | }else{ 508 | bottom = bounds['b']; 509 | } 510 | } 511 | 512 | image = actx.getImageData(left, top, right-left, bottom-top); 513 | imageData = image.data; 514 | length = imageData.length; 515 | // loop thru the area 516 | for(var i=3; i < length; i+=4){ 517 | 518 | // [0] -> r, [1] -> g, [2] -> b, [3] -> alpha 519 | alpha = imageData[i], 520 | offset = alpha*4; 521 | 522 | if(!offset) 523 | continue; 524 | 525 | // we ve started with i=3 526 | // set the new r, g and b values 527 | finalAlpha = (alpha < opacity)?alpha:opacity; 528 | imageData[i-3]=palette[offset]; 529 | imageData[i-2]=palette[offset+1]; 530 | imageData[i-1]=palette[offset+2]; 531 | 532 | if (premultiplyAlpha) { 533 | // To fix browsers that premultiply incorrectly, we'll pass in a value scaled 534 | // appropriately so when the multiplication happens the correct value will result. 535 | imageData[i-3] /= 255/finalAlpha; 536 | imageData[i-2] /= 255/finalAlpha; 537 | imageData[i-1] /= 255/finalAlpha; 538 | } 539 | 540 | // we want the heatmap to have a gradient from transparent to the colors 541 | // as long as alpha is lower than the defined opacity (maximum), we'll use the alpha value 542 | imageData[i] = finalAlpha; 543 | } 544 | // the rgb data manipulation didn't affect the ImageData object(defined on the top) 545 | // after the manipulation process we have to set the manipulated data to the ImageData object 546 | image.data = imageData; 547 | ctx.putImageData(image, left, top); 548 | }, 549 | drawAlpha: function(x, y, count, colorize){ 550 | // storing the variables because they will be often used 551 | var me = this, 552 | radius = me.get("radius"), 553 | ctx = me.get("actx"), 554 | max = me.get("max"), 555 | bounds = me.get("bounds"), 556 | xb = x - (1.5 * radius) >> 0, yb = y - (1.5 * radius) >> 0, 557 | xc = x + (1.5 * radius) >> 0, yc = y + (1.5 * radius) >> 0; 558 | 559 | ctx.shadowColor = ('rgba(0,0,0,'+((count)?(count/me.store.max):'0.1')+')'); 560 | 561 | ctx.shadowOffsetX = 15000; 562 | ctx.shadowOffsetY = 15000; 563 | ctx.shadowBlur = 15; 564 | 565 | ctx.beginPath(); 566 | ctx.arc(x - 15000, y - 15000, radius, 0, Math.PI * 2, true); 567 | ctx.closePath(); 568 | ctx.fill(); 569 | if(colorize){ 570 | // finally colorize the area 571 | me.colorize(xb,yb); 572 | }else{ 573 | // or update the boundaries for the area that then should be colorized 574 | if(xb < bounds["l"]){ 575 | bounds["l"] = xb; 576 | } 577 | if(yb < bounds["t"]){ 578 | bounds["t"] = yb; 579 | } 580 | if(xc > bounds['r']){ 581 | bounds['r'] = xc; 582 | } 583 | if(yc > bounds['b']){ 584 | bounds['b'] = yc; 585 | } 586 | } 587 | }, 588 | toggleDisplay: function(){ 589 | var me = this, 590 | visible = me.get("visible"), 591 | canvas = me.get("canvas"); 592 | 593 | if(!visible) 594 | canvas.style.display = "block"; 595 | else 596 | canvas.style.display = "none"; 597 | 598 | me.set("visible", !visible); 599 | }, 600 | // dataURL export 601 | getImageData: function(){ 602 | return this.get("canvas").toDataURL(); 603 | }, 604 | clear: function(){ 605 | var me = this, 606 | w = me.get("width"), 607 | h = me.get("height"); 608 | 609 | me.store.set("data",[]); 610 | // @TODO: reset stores max to 1 611 | //me.store.max = 1; 612 | me.get("ctx").clearRect(0,0,w,h); 613 | me.get("actx").clearRect(0,0,w,h); 614 | }, 615 | cleanup: function(){ 616 | var me = this; 617 | console.log(me.get("element")); 618 | console.log(me.get("canvas")); 619 | me.get("element").removeChild(me.get("canvas")); 620 | } 621 | }; 622 | 623 | return { 624 | create: function(config){ 625 | return new heatmap(config); 626 | }, 627 | util: { 628 | mousePosition: function(ev){ 629 | // this doesn't work right 630 | // rather use 631 | /* 632 | // this = element to observe 633 | var x = ev.pageX - this.offsetLeft; 634 | var y = ev.pageY - this.offsetTop; 635 | 636 | */ 637 | var x, y; 638 | 639 | if (ev.layerX) { // Firefox 640 | x = ev.layerX; 641 | y = ev.layerY; 642 | } else if (ev.offsetX) { // Opera 643 | x = ev.offsetX; 644 | y = ev.offsetY; 645 | } 646 | if(typeof(x)=='undefined') 647 | return; 648 | 649 | return [x,y]; 650 | } 651 | } 652 | }; 653 | })(); 654 | w.h337 = w.heatmapFactory = heatmapFactory; 655 | })(window); 656 | -------------------------------------------------------------------------------- /views/index.non-compression.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 361 | 362 | 363 | 364 | 365 |
    366 |

    Welcome to Halifax Crime Maps

    367 |

    368 | Get the inside scoop on the crime that darkens our Halifax streets. 369 |
    370 | Apply filters, drag the progress bar, or pause the animation to see each crime. 371 |
    372 | As the Halifax police publish new data the animation grows. 373 |

    374 | 375 |
    376 |
    377 |
    378 |
    379 |
    380 |
    381 | We can only pack so much awesome into your mobile device, visit on a computer for the full effect. 382 |
    383 | 384 | Lets Roll 385 | 386 |
    387 | 388 | 389 |
    390 |

    About Halifax Crime Maps

    391 |

    392 | Halifax Crime Maps is an animated heatmap of crime in Halifax based on the the weekly released data from OpenDataHalifax. 393 |

    394 | Our heatmap show the density of crime in Halifax and how it has evolved over time. You can apply filters to target spesific types of crime, drag the progress bar to spesific dates, or pause the animation and see the individual crime markers. 395 |

    396 | Since this site uses some pretty heavy HTML5 features it works best in Google Chrome but will still work(with certain features disabled) on mobile devices or any new(ish) browser. 397 | 398 |

    399 |

    400 |
    401 | 402 | 403 | 409 | 410 |
    411 | options 412 | years 413 | about 414 | 415 | 418 | 419 | 430 |
    431 | 432 |
    433 | 434 |
    435 |
    436 |
    437 |
    438 |
    drag me
    to jump dates
    439 |
    440 |
    441 |
    442 | 443 | 444 | 445 | 964 | 965 | --------------------------------------------------------------------------------