├── .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 |
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 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
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 |
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 |
431 |
432 |
433 |
434 |
435 |
436 |
437 |
438 |
drag me to jump dates
439 |
440 |
441 |
442 |
443 |
444 |
445 |
964 |
965 |
--------------------------------------------------------------------------------