├── README.md ├── gridmap.coffee ├── gridmap.js ├── index.html └── us.json /README.md: -------------------------------------------------------------------------------- 1 | Gridmap 2 | ======= 3 | 4 | ![enter image description here](https://lh3.googleusercontent.com/UU7MLms-Z3fMRoRdEhT7Y1Z4KSHqXE66gyzBwxLJYDI=s0 "gridmap") 5 | 6 | This is an attempt to create dot grid maps with **d3.js**. 7 | 8 | Dot grid maps could also be done with [Kartograph](http://kartograph.org/) and Gregor Aisch wrote a neat description of what a dot grid map is: 9 | 10 | > In 1967, the French cartographer Jaques Bertin suggested the use of graduated sizes in a regular pattern as alternative to chroropleth maps. 11 | 12 | >The most notable advantage is that one no longer need to choose between showing the *quantity* or *density* of a distribution, since the regular pattern shows both at the same time: You can compare the *density* by looking at individual circles while still getting an impression of the total *quantity* for each department. 13 | 14 | For further details refer to *Semiology of Graphics*, by Jacques Bertin. 15 | 16 | 17 | -------------------- 18 | 19 | ### API 20 | 21 | ``` 22 | chart = gridmap() 23 | .data(data) 24 | .width(width) 25 | .height(height) 26 | .key("id") 27 | .side(5) 28 | .isDensity(true) 29 | .projection(projection) 30 | .features(features) 31 | .fill("black"); 32 | 33 | d3.select("#gridmap").call(chart); 34 | ``` 35 | 36 | ---------------------- 37 | 38 | ### Notes 39 | - `data` is a `d3.map()` object linking feature names (`key`) to the associated data. It can be passed in the form of *quantity distribution* (`q`) or in the form of *density distribution* (`d`), setting `isDensity` to `false` or `true` respectively. 40 | - `key` is the attribute that identifies the feature (usually an `id`). 41 | - `side` is the maximum grid-dot diameter in pixel. 42 | - `projection` is a ` d3.geo.projection`. Use [equal-area](http://en.wikipedia.org/wiki/Map_projection#Equal-area) projections, dotgrid maps assume the projection preserves area measure. 43 | - some map features may be not covered by any grid-dot, in that case the function adds the features data to the grid-dot nearest to the feature centroid. The density value associated to the grid-dot is calculated as: 44 | - `sum(d * A)/sum(A)` in the case data si passed as *density distribution* 45 | - `sum(q)/sum(A)` in the case data si passed as *quantity distribution* 46 | where `A` is the feature area and the summation runs over the list of features associated to the grid-dot. 47 | 48 | 49 | ----------------------------- 50 | 51 | ### Resources 52 | 53 | - Code for [point-in-polygon](https://github.com/substack/point-in-polygon) 54 | - [PNPOLY](http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html) Point Inclusion in Polygon Test (W. Randolph Franklin) 55 | - [Point in Polygon Strategies](http://erich.realtimerendering.com/ptinpoly/) 56 | 57 | ------ 58 | 59 | > Written with [StackEdit](https://stackedit.io/). -------------------------------------------------------------------------------- /gridmap.coffee: -------------------------------------------------------------------------------- 1 | root = exports ? this 2 | 3 | # helper functions ----------------------------------------------------- 4 | 5 | flat = (type, arr) -> 6 | flatten = (polygon) -> 7 | polygon.reduce (a, b) -> a.concat [[0,0]].concat b 8 | switch type 9 | when "Polygon" then m = flatten arr 10 | when "MultiPolygon" then m = flatten (flatten polygon for polygon in arr) 11 | [[0,0]].concat m.concat [[0,0]] 12 | 13 | subGrid = (box,side) -> 14 | x = 1 + Math.floor box[0][0] / side 15 | y = 1 + Math.floor box[0][1] / side 16 | x1 = Math.floor box[1][0] / side 17 | y1 = Math.floor box[1][1] / side 18 | if x1 >= x and y1 >= y 19 | ([i, j] for i in [x..x1] for j in [y..y1]).reduce (a, b) -> a.concat b 20 | else 21 | [] 22 | 23 | isInside = (point, vs) -> 24 | # ray-casting algorithm based on 25 | # http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html 26 | x = point[0] 27 | y = point[1] 28 | inside = false 29 | j = vs.length - 1 30 | for i in [0..vs.length-1] 31 | xi = vs[i][0] 32 | yi = vs[i][1] 33 | xj = vs[j][0] 34 | yj = vs[j][1] 35 | intersect = ((yi > y) isnt (yj > y)) and 36 | (x < (xj - xi) * (y - yi) / (yj - yi) + xi) 37 | if intersect then inside = !inside 38 | j = i 39 | inside 40 | 41 | # end helper functions ------------------------------------------------- 42 | 43 | root.gridmap = () -> 44 | 45 | # ---- Default Values ------------------------------------------------ 46 | 47 | projection = undefined # d3.geo projection 48 | data = undefined # d3.map() mapping key to data 49 | features = undefined # array of map features 50 | isDensity = undefined # set to `true` if data define a density 51 | 52 | side = 10 # side of the cells in pixel 53 | key = "id" # name of the attribute mapping features to data 54 | width = 500 55 | height = 500 56 | fill = "#343434" 57 | 58 | grid = d3.map() 59 | 60 | # -------------------------------------------------------------------- 61 | 62 | chart = (selection) -> 63 | 64 | w = width 65 | h = height 66 | 67 | path = d3.geoPath() 68 | .projection projection 69 | 70 | radius = d3.scaleLinear() 71 | .range [0, side / 2 * 0.9] 72 | 73 | area = d3.map() 74 | centroid = d3.map() 75 | for f in features 76 | area.set f[key], path.area(f) / (w * h) 77 | 78 | svg = selection 79 | .append "svg" 80 | .attr "width", w 81 | .attr "height", h 82 | .attr "viewBox", "0 0 "+w+" "+h 83 | 84 | map = svg 85 | .append "g" 86 | 87 | map.selectAll "path" 88 | .data features 89 | .enter() 90 | .append "path" 91 | .style "opacity", 0 92 | .attr "d", path 93 | 94 | # define the grid 95 | for f in features 96 | g = f.geometry 97 | if g.type in ["Polygon","MultiPolygon"] 98 | box = path.bounds f 99 | points = subGrid box, side 100 | value = [f[key]] 101 | if points.length 102 | polygon = flat g.type, g.coordinates 103 | for [i,j] in points 104 | x = side * i 105 | y = side * j 106 | coords = projection.invert [x, y] 107 | ii = isInside coords, polygon 108 | if ii 109 | grid.set i+","+j, {keys: value, x: x, y: y} 110 | else 111 | c = path.centroid f 112 | if c then centroid.set f[key], c 113 | 114 | # add not hitted features to the nearest cell 115 | centroid.each (k, v) -> 116 | i = Math.floor v[0] / side 117 | j = Math.floor v[1] / side 118 | try 119 | grid.get(i+","+j).keys.push(k) 120 | 121 | density = (a) -> 122 | if isDensity 123 | then num = d3.sum ( data.get(j) * area.get(j) for j in a ) 124 | else num = d3.sum ( data.get(j) for j in a ) 125 | den = d3.sum ( area.get(j) for j in a ) 126 | if den then num / den else 0 127 | 128 | dataGrid = ( { 129 | value: density(k.keys) 130 | x: k.x 131 | y: k.y 132 | } for k in grid.values() when k.keys.length ) 133 | 134 | dots = map 135 | .selectAll ".gridmap-dot" 136 | .data dataGrid 137 | 138 | radius 139 | .domain [ 0, d3.max dataGrid, (d) -> Math.sqrt d.value ] 140 | 141 | # enter 142 | dots.enter() 143 | .append "circle" 144 | .attr "class", "gridmap-dot" 145 | .attr "cx", (d) -> d.x 146 | .attr "cy", (d) -> d.y 147 | .attr "r", (d) -> radius Math.sqrt d.value 148 | .style "fill", fill 149 | 150 | 151 | # ---- Getter/Setter Methods ----------------------------------------- 152 | 153 | chart.width = (_) -> 154 | width = _ 155 | chart 156 | 157 | chart.height = (_) -> 158 | height = _ 159 | chart 160 | 161 | chart.side = (_) -> 162 | side = _ 163 | chart 164 | 165 | chart.key = (_) -> 166 | key = _ 167 | chart 168 | 169 | chart.data = (_) -> 170 | data = _ 171 | chart 172 | 173 | chart.isDensity = (_) -> 174 | isDensity = _ 175 | chart 176 | 177 | chart.features = (_) -> 178 | features = _ 179 | chart 180 | 181 | chart.projection = (_) -> 182 | projection = _ 183 | chart 184 | 185 | chart.fill = (_) -> 186 | fill = _ 187 | chart 188 | 189 | # -------------------------------------------------------------------- 190 | 191 | chart 192 | -------------------------------------------------------------------------------- /gridmap.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.9.0 2 | (function() { 3 | var flat, isInside, root, subGrid; 4 | 5 | root = typeof exports !== "undefined" && exports !== null ? exports : this; 6 | 7 | flat = function(type, arr) { 8 | var flatten, m, polygon; 9 | flatten = function(polygon) { 10 | return polygon.reduce(function(a, b) { 11 | return a.concat([[0, 0]].concat(b)); 12 | }); 13 | }; 14 | switch (type) { 15 | case "Polygon": 16 | m = flatten(arr); 17 | break; 18 | case "MultiPolygon": 19 | m = flatten((function() { 20 | var _i, _len, _results; 21 | _results = []; 22 | for (_i = 0, _len = arr.length; _i < _len; _i++) { 23 | polygon = arr[_i]; 24 | _results.push(flatten(polygon)); 25 | } 26 | return _results; 27 | })()); 28 | } 29 | return [[0, 0]].concat(m.concat([[0, 0]])); 30 | }; 31 | 32 | subGrid = function(box, side) { 33 | var i, j, x, x1, y, y1; 34 | x = 1 + Math.floor(box[0][0] / side); 35 | y = 1 + Math.floor(box[0][1] / side); 36 | x1 = Math.floor(box[1][0] / side); 37 | y1 = Math.floor(box[1][1] / side); 38 | if (x1 >= x && y1 >= y) { 39 | return ((function() { 40 | var _i, _results; 41 | _results = []; 42 | for (j = _i = y; y <= y1 ? _i <= y1 : _i >= y1; j = y <= y1 ? ++_i : --_i) { 43 | _results.push((function() { 44 | var _j, _results1; 45 | _results1 = []; 46 | for (i = _j = x; x <= x1 ? _j <= x1 : _j >= x1; i = x <= x1 ? ++_j : --_j) { 47 | _results1.push([i, j]); 48 | } 49 | return _results1; 50 | })()); 51 | } 52 | return _results; 53 | })()).reduce(function(a, b) { 54 | return a.concat(b); 55 | }); 56 | } else { 57 | return []; 58 | } 59 | }; 60 | 61 | isInside = function(point, vs) { 62 | var i, inside, intersect, j, x, xi, xj, y, yi, yj, _i, _ref; 63 | x = point[0]; 64 | y = point[1]; 65 | inside = false; 66 | j = vs.length - 1; 67 | for (i = _i = 0, _ref = vs.length - 1; 0 <= _ref ? _i <= _ref : _i >= _ref; i = 0 <= _ref ? ++_i : --_i) { 68 | xi = vs[i][0]; 69 | yi = vs[i][1]; 70 | xj = vs[j][0]; 71 | yj = vs[j][1]; 72 | intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); 73 | if (intersect) { 74 | inside = !inside; 75 | } 76 | j = i; 77 | } 78 | return inside; 79 | }; 80 | 81 | root.gridmap = function() { 82 | var chart, data, features, fill, grid, height, isDensity, key, projection, side, width; 83 | projection = void 0; 84 | data = void 0; 85 | features = void 0; 86 | isDensity = void 0; 87 | side = 10; 88 | key = "id"; 89 | width = 500; 90 | height = 500; 91 | fill = "#343434"; 92 | grid = d3.map(); 93 | chart = function(selection) { 94 | var area, box, c, centroid, coords, dataGrid, density, dots, f, g, h, i, ii, j, k, map, path, points, polygon, radius, svg, value, w, x, y, _i, _j, _k, _len, _len1, _len2, _ref, _ref1; 95 | w = width; 96 | h = height; 97 | path = d3.geoPath().projection(projection); 98 | radius = d3.scaleLinear().range([0, side / 2 * 0.9]); 99 | area = d3.map(); 100 | centroid = d3.map(); 101 | for (_i = 0, _len = features.length; _i < _len; _i++) { 102 | f = features[_i]; 103 | area.set(f[key], path.area(f) / (w * h)); 104 | } 105 | svg = selection.append("svg").attr("width", w).attr("height", h).attr("viewBox", "0 0 " + w + " " + h); 106 | map = svg.append("g"); 107 | map.selectAll("path").data(features).enter().append("path").style("opacity", 0).attr("d", path); 108 | for (_j = 0, _len1 = features.length; _j < _len1; _j++) { 109 | f = features[_j]; 110 | g = f.geometry; 111 | if ((_ref = g.type) === "Polygon" || _ref === "MultiPolygon") { 112 | box = path.bounds(f); 113 | points = subGrid(box, side); 114 | value = [f[key]]; 115 | if (points.length) { 116 | polygon = flat(g.type, g.coordinates); 117 | for (_k = 0, _len2 = points.length; _k < _len2; _k++) { 118 | _ref1 = points[_k], i = _ref1[0], j = _ref1[1]; 119 | x = side * i; 120 | y = side * j; 121 | coords = projection.invert([x, y]); 122 | ii = isInside(coords, polygon); 123 | if (ii) { 124 | grid.set(i + "," + j, { 125 | keys: value, 126 | x: x, 127 | y: y 128 | }); 129 | } 130 | } 131 | } else { 132 | c = path.centroid(f); 133 | if (c) { 134 | centroid.set(f[key], c); 135 | } 136 | } 137 | } 138 | } 139 | centroid.each(function(k, v) { 140 | i = Math.floor(v[0] / side); 141 | j = Math.floor(v[1] / side); 142 | try { 143 | return grid.get(i + "," + j).keys.push(k); 144 | } catch (_error) {} 145 | }); 146 | density = function(a) { 147 | var den, num; 148 | if (isDensity) { 149 | num = d3.sum((function() { 150 | var _l, _len3, _results; 151 | _results = []; 152 | for (_l = 0, _len3 = a.length; _l < _len3; _l++) { 153 | j = a[_l]; 154 | _results.push(data.get(j) * area.get(j)); 155 | } 156 | return _results; 157 | })()); 158 | } else { 159 | num = d3.sum((function() { 160 | var _l, _len3, _results; 161 | _results = []; 162 | for (_l = 0, _len3 = a.length; _l < _len3; _l++) { 163 | j = a[_l]; 164 | _results.push(data.get(j)); 165 | } 166 | return _results; 167 | })()); 168 | } 169 | den = d3.sum((function() { 170 | var _l, _len3, _results; 171 | _results = []; 172 | for (_l = 0, _len3 = a.length; _l < _len3; _l++) { 173 | j = a[_l]; 174 | _results.push(area.get(j)); 175 | } 176 | return _results; 177 | })()); 178 | if (den) { 179 | return num / den; 180 | } else { 181 | return 0; 182 | } 183 | }; 184 | dataGrid = (function() { 185 | var _l, _len3, _ref2, _results; 186 | _ref2 = grid.values(); 187 | _results = []; 188 | for (_l = 0, _len3 = _ref2.length; _l < _len3; _l++) { 189 | k = _ref2[_l]; 190 | if (k.keys.length) { 191 | _results.push({ 192 | value: density(k.keys), 193 | x: k.x, 194 | y: k.y 195 | }); 196 | } 197 | } 198 | return _results; 199 | })(); 200 | dots = map.selectAll(".gridmap-dot").data(dataGrid); 201 | radius.domain([ 202 | 0, d3.max(dataGrid, function(d) { 203 | return Math.sqrt(d.value); 204 | }) 205 | ]); 206 | return dots.enter().append("circle").attr("class", "gridmap-dot").attr("cx", function(d) { 207 | return d.x; 208 | }).attr("cy", function(d) { 209 | return d.y; 210 | }).attr("r", function(d) { 211 | return radius(Math.sqrt(d.value)); 212 | }).style("fill", fill); 213 | }; 214 | chart.width = function(_) { 215 | width = _; 216 | return chart; 217 | }; 218 | chart.height = function(_) { 219 | height = _; 220 | return chart; 221 | }; 222 | chart.side = function(_) { 223 | side = _; 224 | return chart; 225 | }; 226 | chart.key = function(_) { 227 | key = _; 228 | return chart; 229 | }; 230 | chart.data = function(_) { 231 | data = _; 232 | return chart; 233 | }; 234 | chart.isDensity = function(_) { 235 | isDensity = _; 236 | return chart; 237 | }; 238 | chart.features = function(_) { 239 | features = _; 240 | return chart; 241 | }; 242 | chart.projection = function(_) { 243 | projection = _; 244 | return chart; 245 | }; 246 | chart.fill = function(_) { 247 | fill = _; 248 | return chart; 249 | }; 250 | return chart; 251 | }; 252 | 253 | }).call(this); 254 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 57 | --------------------------------------------------------------------------------