├── assets ├── logo.png ├── logo_square.png ├── logo_inverted.png └── style.css ├── docs ├── public │ ├── fonts │ │ ├── aller-bold.eot │ │ ├── aller-bold.ttf │ │ ├── aller-bold.woff │ │ ├── aller-light.eot │ │ ├── aller-light.ttf │ │ ├── aller-light.woff │ │ ├── novecento-bold.eot │ │ ├── novecento-bold.ttf │ │ └── novecento-bold.woff │ └── stylesheets │ │ └── normalize.css ├── docco.css └── index.html ├── currency_symbols.json ├── .editorconfig ├── .jshintrc ├── LICENSE ├── index.html ├── package.json ├── README.md └── index.js /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/assets/logo.png -------------------------------------------------------------------------------- /assets/logo_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/assets/logo_square.png -------------------------------------------------------------------------------- /assets/logo_inverted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/assets/logo_inverted.png -------------------------------------------------------------------------------- /docs/public/fonts/aller-bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/docs/public/fonts/aller-bold.eot -------------------------------------------------------------------------------- /docs/public/fonts/aller-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/docs/public/fonts/aller-bold.ttf -------------------------------------------------------------------------------- /docs/public/fonts/aller-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/docs/public/fonts/aller-bold.woff -------------------------------------------------------------------------------- /docs/public/fonts/aller-light.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/docs/public/fonts/aller-light.eot -------------------------------------------------------------------------------- /docs/public/fonts/aller-light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/docs/public/fonts/aller-light.ttf -------------------------------------------------------------------------------- /docs/public/fonts/aller-light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/docs/public/fonts/aller-light.woff -------------------------------------------------------------------------------- /docs/public/fonts/novecento-bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/docs/public/fonts/novecento-bold.eot -------------------------------------------------------------------------------- /docs/public/fonts/novecento-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/docs/public/fonts/novecento-bold.ttf -------------------------------------------------------------------------------- /docs/public/fonts/novecento-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmcw/coffeedex/gh-pages/docs/public/fonts/novecento-bold.woff -------------------------------------------------------------------------------- /currency_symbols.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | "USD", 4 | "$" 5 | ], 6 | [ 7 | "GBP", 8 | "£" 9 | ], 10 | [ 11 | "EUR", 12 | "€" 13 | ] 14 | ] 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [**.js] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | [**.jsx] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "evil": true, 3 | "smarttabs": true, 4 | "strict": false, 5 | "globalstrict": false, 6 | "asi": false, 7 | "esnext": true, 8 | "globals": { 9 | "window": true, 10 | "require": true, 11 | "module": true, 12 | "document": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2014, Tom MacWright 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | COFFEEDEX 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /assets/style.css: -------------------------------------------------------------------------------- 1 | #coffee a { 2 | color:#fff; 3 | } 4 | 5 | #coffee p a { 6 | text-decoration:underline; 7 | } 8 | 9 | 10 | /* http://www.colourlovers.com/palette/45488/french_vanilla */ 11 | body { 12 | background: #5f3711; 13 | color:#eee; 14 | } 15 | 16 | .fill-coffee { 17 | background: #5f3711; 18 | } 19 | 20 | .coffee-select { 21 | border: 0; 22 | font-size:35px; 23 | display:inline-block; 24 | vertical-align: middle; 25 | width:40%; 26 | background:transparent; 27 | color:#ddd; 28 | border-radius:0; 29 | -webkit-appearance:none; 30 | line-height:35px; 31 | box-sizing:border-box; 32 | height:55px; 33 | padding:5px 5px 5px 20%; 34 | margin:0; 35 | } 36 | 37 | input[type=number].coffee-input { 38 | border: 0; 39 | font-size:65px; 40 | line-height:60px; 41 | height:85px; 42 | display:inline-block; 43 | color:#fff; 44 | background:transparent; 45 | width:60%; 46 | vertical-align: middle; 47 | padding:5px; 48 | text-align:left; 49 | } 50 | 51 | .limit-mobile { 52 | width:300px; 53 | margin:0 auto; 54 | } 55 | 56 | .price-tag { 57 | display:inline-block; 58 | width:50px; 59 | text-align:center; 60 | margin-right:10px; 61 | } 62 | 63 | .italic { 64 | font-style:italic; 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coffeedex", 3 | "version": "1.0.0", 4 | "description": "coffee price index in openstreetmap", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "watchify index.js -o bundle.js & serve", 9 | "build": "NODE_ENV=production browserify index.js | uglifyjs -c -m > bundle.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git@github.com:tmcw/coffeedex.git" 14 | }, 15 | "browserify": { 16 | "transform": [ 17 | "6to5-browserify" 18 | ] 19 | }, 20 | "keywords": [ 21 | "coffee", 22 | "price", 23 | "index", 24 | "ui", 25 | "client" 26 | ], 27 | "author": "Tom MacWright", 28 | "license": "ISC", 29 | "bugs": { 30 | "url": "https://github.com/tmcw/coffeedex/issues" 31 | }, 32 | "homepage": "https://github.com/tmcw/coffeedex", 33 | "devDependencies": { 34 | "6to5-browserify": "^1.3.0", 35 | "browserify": "^6.3.3", 36 | "minifyify": "^4.4.0", 37 | "osm-auth": "^1.0.0", 38 | "react": "^0.12.1", 39 | "serve": "^1.4.0", 40 | "uglify-js": "^2.4.15", 41 | "watchify": "^2.1.1" 42 | }, 43 | "dependencies": { 44 | "haversine": "^1.0.0", 45 | "react-router": "^0.11.4", 46 | "reflux": "^0.2.1", 47 | "xhr": "^1.17.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # COFFEE DEX 2 | 3 | _A collaborative solution to the problem of_ 4 | 5 | > How much does a cup of coffee for here cost, everywhere? 6 | 7 | * A [React](http://facebook.github.io/react/) application written in 8 | ES6 that uses 6to5 to make the code run everywhere. 9 | * Uses [Reflux](https://www.npmjs.org/package/reflux) for data flows. 10 | * Uses [react-router](https://github.com/rackt/react-router) for manage pages 11 | * Map data comes from the [OpenStreetMap API 0.6](http://wiki.openstreetmap.org/wiki/API_v0.6) 12 | and [Overpass API](http://wiki.openstreetmap.org/wiki/Overpass_API) 13 | * Authenticates against [OpenStreetMap](http://www.openstreetmap.org/) 14 | with [osm-auth](https://github.com/osmlab/osm-auth) 15 | * Uses [Base](https://www.mapbox.com/base/) for CSS 16 | 17 | The code structure is simple: **all JavaScript is in index.js**. 18 | 19 | ## Development 20 | 21 | This project uses [browserify](http://browserify.org/) to compile 22 | the source code in `index.js` into the bundle of JavaScript in `bundle.js` 23 | that your browser interprets. You'll need [node.js](http://nodejs.org/) 24 | to install the dependencies of this project and develop on it. 25 | 26 | * `npm install`: install dependencies 27 | * `npm start`: start the development server that compiles your JavaScript and 28 | serves the site at http://localhost:3000/ 29 | * `npm run build`: build a production-ready `bundle.js` that you can commit 30 | and use on the public website. 31 | 32 | ## See Also 33 | 34 | * [logo](http://www.clker.com/clipart-13288.html) 35 | -------------------------------------------------------------------------------- /docs/public/stylesheets/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v2.0.1 | MIT License | git.io/normalize */ 2 | 3 | /* ========================================================================== 4 | HTML5 display definitions 5 | ========================================================================== */ 6 | 7 | /* 8 | * Corrects `block` display not defined in IE 8/9. 9 | */ 10 | 11 | article, 12 | aside, 13 | details, 14 | figcaption, 15 | figure, 16 | footer, 17 | header, 18 | hgroup, 19 | nav, 20 | section, 21 | summary { 22 | display: block; 23 | } 24 | 25 | /* 26 | * Corrects `inline-block` display not defined in IE 8/9. 27 | */ 28 | 29 | audio, 30 | canvas, 31 | video { 32 | display: inline-block; 33 | } 34 | 35 | /* 36 | * Prevents modern browsers from displaying `audio` without controls. 37 | * Remove excess height in iOS 5 devices. 38 | */ 39 | 40 | audio:not([controls]) { 41 | display: none; 42 | height: 0; 43 | } 44 | 45 | /* 46 | * Addresses styling for `hidden` attribute not present in IE 8/9. 47 | */ 48 | 49 | [hidden] { 50 | display: none; 51 | } 52 | 53 | /* ========================================================================== 54 | Base 55 | ========================================================================== */ 56 | 57 | /* 58 | * 1. Sets default font family to sans-serif. 59 | * 2. Prevents iOS text size adjust after orientation change, without disabling 60 | * user zoom. 61 | */ 62 | 63 | html { 64 | font-family: sans-serif; /* 1 */ 65 | -webkit-text-size-adjust: 100%; /* 2 */ 66 | -ms-text-size-adjust: 100%; /* 2 */ 67 | } 68 | 69 | /* 70 | * Removes default margin. 71 | */ 72 | 73 | body { 74 | margin: 0; 75 | } 76 | 77 | /* ========================================================================== 78 | Links 79 | ========================================================================== */ 80 | 81 | /* 82 | * Addresses `outline` inconsistency between Chrome and other browsers. 83 | */ 84 | 85 | a:focus { 86 | outline: thin dotted; 87 | } 88 | 89 | /* 90 | * Improves readability when focused and also mouse hovered in all browsers. 91 | */ 92 | 93 | a:active, 94 | a:hover { 95 | outline: 0; 96 | } 97 | 98 | /* ========================================================================== 99 | Typography 100 | ========================================================================== */ 101 | 102 | /* 103 | * Addresses `h1` font sizes within `section` and `article` in Firefox 4+, 104 | * Safari 5, and Chrome. 105 | */ 106 | 107 | h1 { 108 | font-size: 2em; 109 | } 110 | 111 | /* 112 | * Addresses styling not present in IE 8/9, Safari 5, and Chrome. 113 | */ 114 | 115 | abbr[title] { 116 | border-bottom: 1px dotted; 117 | } 118 | 119 | /* 120 | * Addresses style set to `bolder` in Firefox 4+, Safari 5, and Chrome. 121 | */ 122 | 123 | b, 124 | strong { 125 | font-weight: bold; 126 | } 127 | 128 | /* 129 | * Addresses styling not present in Safari 5 and Chrome. 130 | */ 131 | 132 | dfn { 133 | font-style: italic; 134 | } 135 | 136 | /* 137 | * Addresses styling not present in IE 8/9. 138 | */ 139 | 140 | mark { 141 | background: #ff0; 142 | color: #000; 143 | } 144 | 145 | 146 | /* 147 | * Corrects font family set oddly in Safari 5 and Chrome. 148 | */ 149 | 150 | code, 151 | kbd, 152 | pre, 153 | samp { 154 | font-family: monospace, serif; 155 | font-size: 1em; 156 | } 157 | 158 | /* 159 | * Improves readability of pre-formatted text in all browsers. 160 | */ 161 | 162 | pre { 163 | white-space: pre; 164 | white-space: pre-wrap; 165 | word-wrap: break-word; 166 | } 167 | 168 | /* 169 | * Sets consistent quote types. 170 | */ 171 | 172 | q { 173 | quotes: "\201C" "\201D" "\2018" "\2019"; 174 | } 175 | 176 | /* 177 | * Addresses inconsistent and variable font size in all browsers. 178 | */ 179 | 180 | small { 181 | font-size: 80%; 182 | } 183 | 184 | /* 185 | * Prevents `sub` and `sup` affecting `line-height` in all browsers. 186 | */ 187 | 188 | sub, 189 | sup { 190 | font-size: 75%; 191 | line-height: 0; 192 | position: relative; 193 | vertical-align: baseline; 194 | } 195 | 196 | sup { 197 | top: -0.5em; 198 | } 199 | 200 | sub { 201 | bottom: -0.25em; 202 | } 203 | 204 | /* ========================================================================== 205 | Embedded content 206 | ========================================================================== */ 207 | 208 | /* 209 | * Removes border when inside `a` element in IE 8/9. 210 | */ 211 | 212 | img { 213 | border: 0; 214 | } 215 | 216 | /* 217 | * Corrects overflow displayed oddly in IE 9. 218 | */ 219 | 220 | svg:not(:root) { 221 | overflow: hidden; 222 | } 223 | 224 | /* ========================================================================== 225 | Figures 226 | ========================================================================== */ 227 | 228 | /* 229 | * Addresses margin not present in IE 8/9 and Safari 5. 230 | */ 231 | 232 | figure { 233 | margin: 0; 234 | } 235 | 236 | /* ========================================================================== 237 | Forms 238 | ========================================================================== */ 239 | 240 | /* 241 | * Define consistent border, margin, and padding. 242 | */ 243 | 244 | fieldset { 245 | border: 1px solid #c0c0c0; 246 | margin: 0 2px; 247 | padding: 0.35em 0.625em 0.75em; 248 | } 249 | 250 | /* 251 | * 1. Corrects color not being inherited in IE 8/9. 252 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 253 | */ 254 | 255 | legend { 256 | border: 0; /* 1 */ 257 | padding: 0; /* 2 */ 258 | } 259 | 260 | /* 261 | * 1. Corrects font family not being inherited in all browsers. 262 | * 2. Corrects font size not being inherited in all browsers. 263 | * 3. Addresses margins set differently in Firefox 4+, Safari 5, and Chrome 264 | */ 265 | 266 | button, 267 | input, 268 | select, 269 | textarea { 270 | font-family: inherit; /* 1 */ 271 | font-size: 100%; /* 2 */ 272 | margin: 0; /* 3 */ 273 | } 274 | 275 | /* 276 | * Addresses Firefox 4+ setting `line-height` on `input` using `!important` in 277 | * the UA stylesheet. 278 | */ 279 | 280 | button, 281 | input { 282 | line-height: normal; 283 | } 284 | 285 | /* 286 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 287 | * and `video` controls. 288 | * 2. Corrects inability to style clickable `input` types in iOS. 289 | * 3. Improves usability and consistency of cursor style between image-type 290 | * `input` and others. 291 | */ 292 | 293 | button, 294 | html input[type="button"], /* 1 */ 295 | input[type="reset"], 296 | input[type="submit"] { 297 | -webkit-appearance: button; /* 2 */ 298 | cursor: pointer; /* 3 */ 299 | } 300 | 301 | /* 302 | * Re-set default cursor for disabled elements. 303 | */ 304 | 305 | button[disabled], 306 | input[disabled] { 307 | cursor: default; 308 | } 309 | 310 | /* 311 | * 1. Addresses box sizing set to `content-box` in IE 8/9. 312 | * 2. Removes excess padding in IE 8/9. 313 | */ 314 | 315 | input[type="checkbox"], 316 | input[type="radio"] { 317 | box-sizing: border-box; /* 1 */ 318 | padding: 0; /* 2 */ 319 | } 320 | 321 | /* 322 | * 1. Addresses `appearance` set to `searchfield` in Safari 5 and Chrome. 323 | * 2. Addresses `box-sizing` set to `border-box` in Safari 5 and Chrome 324 | * (include `-moz` to future-proof). 325 | */ 326 | 327 | input[type="search"] { 328 | -webkit-appearance: textfield; /* 1 */ 329 | -moz-box-sizing: content-box; 330 | -webkit-box-sizing: content-box; /* 2 */ 331 | box-sizing: content-box; 332 | } 333 | 334 | /* 335 | * Removes inner padding and search cancel button in Safari 5 and Chrome 336 | * on OS X. 337 | */ 338 | 339 | input[type="search"]::-webkit-search-cancel-button, 340 | input[type="search"]::-webkit-search-decoration { 341 | -webkit-appearance: none; 342 | } 343 | 344 | /* 345 | * Removes inner padding and border in Firefox 4+. 346 | */ 347 | 348 | button::-moz-focus-inner, 349 | input::-moz-focus-inner { 350 | border: 0; 351 | padding: 0; 352 | } 353 | 354 | /* 355 | * 1. Removes default vertical scrollbar in IE 8/9. 356 | * 2. Improves readability and alignment in all browsers. 357 | */ 358 | 359 | textarea { 360 | overflow: auto; /* 1 */ 361 | vertical-align: top; /* 2 */ 362 | } 363 | 364 | /* ========================================================================== 365 | Tables 366 | ========================================================================== */ 367 | 368 | /* 369 | * Remove most spacing between table cells. 370 | */ 371 | 372 | table { 373 | border-collapse: collapse; 374 | border-spacing: 0; 375 | } -------------------------------------------------------------------------------- /docs/docco.css: -------------------------------------------------------------------------------- 1 | /*--------------------- Typography ----------------------------*/ 2 | 3 | @font-face { 4 | font-family: 'aller-light'; 5 | src: url('public/fonts/aller-light.eot'); 6 | src: url('public/fonts/aller-light.eot?#iefix') format('embedded-opentype'), 7 | url('public/fonts/aller-light.woff') format('woff'), 8 | url('public/fonts/aller-light.ttf') format('truetype'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | 13 | @font-face { 14 | font-family: 'aller-bold'; 15 | src: url('public/fonts/aller-bold.eot'); 16 | src: url('public/fonts/aller-bold.eot?#iefix') format('embedded-opentype'), 17 | url('public/fonts/aller-bold.woff') format('woff'), 18 | url('public/fonts/aller-bold.ttf') format('truetype'); 19 | font-weight: normal; 20 | font-style: normal; 21 | } 22 | 23 | @font-face { 24 | font-family: 'novecento-bold'; 25 | src: url('public/fonts/novecento-bold.eot'); 26 | src: url('public/fonts/novecento-bold.eot?#iefix') format('embedded-opentype'), 27 | url('public/fonts/novecento-bold.woff') format('woff'), 28 | url('public/fonts/novecento-bold.ttf') format('truetype'); 29 | font-weight: normal; 30 | font-style: normal; 31 | } 32 | 33 | /*--------------------- Layout ----------------------------*/ 34 | html { height: 100%; } 35 | body { 36 | font-family: "aller-light"; 37 | font-size: 14px; 38 | line-height: 18px; 39 | color: #30404f; 40 | margin: 0; padding: 0; 41 | height:100%; 42 | } 43 | #container { min-height: 100%; } 44 | 45 | a { 46 | color: #000; 47 | } 48 | 49 | b, strong { 50 | font-weight: normal; 51 | font-family: "aller-bold"; 52 | } 53 | 54 | p { 55 | margin: 15px 0 0px; 56 | } 57 | .annotation ul, .annotation ol { 58 | margin: 25px 0; 59 | } 60 | .annotation ul li, .annotation ol li { 61 | font-size: 14px; 62 | line-height: 18px; 63 | margin: 10px 0; 64 | } 65 | 66 | h1, h2, h3, h4, h5, h6 { 67 | color: #112233; 68 | line-height: 1em; 69 | font-weight: normal; 70 | font-family: "novecento-bold"; 71 | text-transform: uppercase; 72 | margin: 30px 0 15px 0; 73 | } 74 | 75 | h1 { 76 | margin-top: 40px; 77 | } 78 | 79 | hr { 80 | border: 0; 81 | background: 1px #ddd; 82 | height: 1px; 83 | margin: 20px 0; 84 | } 85 | 86 | pre, tt, code { 87 | font-size: 12px; line-height: 16px; 88 | font-family: Menlo, Monaco, Consolas, "Lucida Console", monospace; 89 | margin: 0; padding: 0; 90 | } 91 | .annotation pre { 92 | display: block; 93 | margin: 0; 94 | padding: 7px 10px; 95 | background: #fcfcfc; 96 | -moz-box-shadow: inset 0 0 10px rgba(0,0,0,0.1); 97 | -webkit-box-shadow: inset 0 0 10px rgba(0,0,0,0.1); 98 | box-shadow: inset 0 0 10px rgba(0,0,0,0.1); 99 | overflow-x: auto; 100 | } 101 | .annotation pre code { 102 | border: 0; 103 | padding: 0; 104 | background: transparent; 105 | } 106 | 107 | 108 | blockquote { 109 | border-left: 5px solid #ccc; 110 | margin: 0; 111 | padding: 1px 0 1px 1em; 112 | } 113 | .sections blockquote p { 114 | font-family: Menlo, Consolas, Monaco, monospace; 115 | font-size: 12px; line-height: 16px; 116 | color: #999; 117 | margin: 10px 0 0; 118 | white-space: pre-wrap; 119 | } 120 | 121 | ul.sections { 122 | list-style: none; 123 | padding:0 0 5px 0;; 124 | margin:0; 125 | } 126 | 127 | /* 128 | Force border-box so that % widths fit the parent 129 | container without overlap because of margin/padding. 130 | 131 | More Info : http://www.quirksmode.org/css/box.html 132 | */ 133 | ul.sections > li > div { 134 | -moz-box-sizing: border-box; /* firefox */ 135 | -ms-box-sizing: border-box; /* ie */ 136 | -webkit-box-sizing: border-box; /* webkit */ 137 | -khtml-box-sizing: border-box; /* konqueror */ 138 | box-sizing: border-box; /* css3 */ 139 | } 140 | 141 | 142 | /*---------------------- Jump Page -----------------------------*/ 143 | #jump_to, #jump_page { 144 | margin: 0; 145 | background: white; 146 | -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777; 147 | -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; 148 | font: 16px Arial; 149 | cursor: pointer; 150 | text-align: right; 151 | list-style: none; 152 | } 153 | 154 | #jump_to a { 155 | text-decoration: none; 156 | } 157 | 158 | #jump_to a.large { 159 | display: none; 160 | } 161 | #jump_to a.small { 162 | font-size: 22px; 163 | font-weight: bold; 164 | color: #676767; 165 | } 166 | 167 | #jump_to, #jump_wrapper { 168 | position: fixed; 169 | right: 0; top: 0; 170 | padding: 10px 15px; 171 | margin:0; 172 | } 173 | 174 | #jump_wrapper { 175 | display: none; 176 | padding:0; 177 | } 178 | 179 | #jump_to:hover #jump_wrapper { 180 | display: block; 181 | } 182 | 183 | #jump_page { 184 | padding: 5px 0 3px; 185 | margin: 0 0 25px 25px; 186 | } 187 | 188 | #jump_page .source { 189 | display: block; 190 | padding: 15px; 191 | text-decoration: none; 192 | border-top: 1px solid #eee; 193 | } 194 | 195 | #jump_page .source:hover { 196 | background: #f5f5ff; 197 | } 198 | 199 | #jump_page .source:first-child { 200 | } 201 | 202 | /*---------------------- Low resolutions (> 320px) ---------------------*/ 203 | @media only screen and (min-width: 320px) { 204 | .pilwrap { display: none; } 205 | 206 | ul.sections > li > div { 207 | display: block; 208 | padding:5px 10px 0 10px; 209 | } 210 | 211 | ul.sections > li > div.annotation ul, ul.sections > li > div.annotation ol { 212 | padding-left: 30px; 213 | } 214 | 215 | ul.sections > li > div.content { 216 | overflow-x:auto; 217 | -webkit-box-shadow: inset 0 0 5px #e5e5ee; 218 | box-shadow: inset 0 0 5px #e5e5ee; 219 | border: 1px solid #dedede; 220 | margin:5px 10px 5px 10px; 221 | padding-bottom: 5px; 222 | } 223 | 224 | ul.sections > li > div.annotation pre { 225 | margin: 7px 0 7px; 226 | padding-left: 15px; 227 | } 228 | 229 | ul.sections > li > div.annotation p tt, .annotation code { 230 | background: #f8f8ff; 231 | border: 1px solid #dedede; 232 | font-size: 12px; 233 | padding: 0 0.2em; 234 | } 235 | } 236 | 237 | /*---------------------- (> 481px) ---------------------*/ 238 | @media only screen and (min-width: 481px) { 239 | #container { 240 | position: relative; 241 | } 242 | body { 243 | background-color: #F5F5FF; 244 | font-size: 15px; 245 | line-height: 21px; 246 | } 247 | pre, tt, code { 248 | line-height: 18px; 249 | } 250 | p, ul, ol { 251 | margin: 0 0 15px; 252 | } 253 | 254 | 255 | #jump_to { 256 | padding: 5px 10px; 257 | } 258 | #jump_wrapper { 259 | padding: 0; 260 | } 261 | #jump_to, #jump_page { 262 | font: 10px Arial; 263 | text-transform: uppercase; 264 | } 265 | #jump_page .source { 266 | padding: 5px 10px; 267 | } 268 | #jump_to a.large { 269 | display: inline-block; 270 | } 271 | #jump_to a.small { 272 | display: none; 273 | } 274 | 275 | 276 | 277 | #background { 278 | position: absolute; 279 | top: 0; bottom: 0; 280 | width: 350px; 281 | background: #fff; 282 | border-right: 1px solid #e5e5ee; 283 | z-index: -1; 284 | } 285 | 286 | ul.sections > li > div.annotation ul, ul.sections > li > div.annotation ol { 287 | padding-left: 40px; 288 | } 289 | 290 | ul.sections > li { 291 | white-space: nowrap; 292 | } 293 | 294 | ul.sections > li > div { 295 | display: inline-block; 296 | } 297 | 298 | ul.sections > li > div.annotation { 299 | max-width: 350px; 300 | min-width: 350px; 301 | min-height: 5px; 302 | padding: 13px; 303 | overflow-x: hidden; 304 | white-space: normal; 305 | vertical-align: top; 306 | text-align: left; 307 | } 308 | ul.sections > li > div.annotation pre { 309 | margin: 15px 0 15px; 310 | padding-left: 15px; 311 | } 312 | 313 | ul.sections > li > div.content { 314 | padding: 13px; 315 | vertical-align: top; 316 | border: none; 317 | -webkit-box-shadow: none; 318 | box-shadow: none; 319 | } 320 | 321 | .pilwrap { 322 | position: relative; 323 | display: inline; 324 | } 325 | 326 | .pilcrow { 327 | font: 12px Arial; 328 | text-decoration: none; 329 | color: #454545; 330 | position: absolute; 331 | top: 3px; left: -20px; 332 | padding: 1px 2px; 333 | opacity: 0; 334 | -webkit-transition: opacity 0.2s linear; 335 | } 336 | .for-h1 .pilcrow { 337 | top: 47px; 338 | } 339 | .for-h2 .pilcrow, .for-h3 .pilcrow, .for-h4 .pilcrow { 340 | top: 35px; 341 | } 342 | 343 | ul.sections > li > div.annotation:hover .pilcrow { 344 | opacity: 1; 345 | } 346 | } 347 | 348 | /*---------------------- (> 1025px) ---------------------*/ 349 | @media only screen and (min-width: 1025px) { 350 | 351 | body { 352 | font-size: 16px; 353 | line-height: 24px; 354 | } 355 | 356 | #background { 357 | width: 525px; 358 | } 359 | ul.sections > li > div.annotation { 360 | max-width: 525px; 361 | min-width: 525px; 362 | padding: 10px 25px 1px 50px; 363 | } 364 | ul.sections > li > div.content { 365 | padding: 9px 15px 16px 25px; 366 | } 367 | } 368 | 369 | /*---------------------- Syntax Highlighting -----------------------------*/ 370 | 371 | td.linenos { background-color: #f0f0f0; padding-right: 10px; } 372 | span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; } 373 | /* 374 | 375 | github.com style (c) Vasily Polovnyov 376 | 377 | */ 378 | 379 | pre code { 380 | display: block; padding: 0.5em; 381 | color: #000; 382 | background: #f8f8ff 383 | } 384 | 385 | pre .hljs-comment, 386 | pre .hljs-template_comment, 387 | pre .hljs-diff .hljs-header, 388 | pre .hljs-javadoc { 389 | color: #408080; 390 | font-style: italic 391 | } 392 | 393 | pre .hljs-keyword, 394 | pre .hljs-assignment, 395 | pre .hljs-literal, 396 | pre .hljs-css .hljs-rule .hljs-keyword, 397 | pre .hljs-winutils, 398 | pre .hljs-javascript .hljs-title, 399 | pre .hljs-lisp .hljs-title, 400 | pre .hljs-subst { 401 | color: #954121; 402 | /*font-weight: bold*/ 403 | } 404 | 405 | pre .hljs-number, 406 | pre .hljs-hexcolor { 407 | color: #40a070 408 | } 409 | 410 | pre .hljs-string, 411 | pre .hljs-tag .hljs-value, 412 | pre .hljs-phpdoc, 413 | pre .hljs-tex .hljs-formula { 414 | color: #219161; 415 | } 416 | 417 | pre .hljs-title, 418 | pre .hljs-id { 419 | color: #19469D; 420 | } 421 | pre .hljs-params { 422 | color: #00F; 423 | } 424 | 425 | pre .hljs-javascript .hljs-title, 426 | pre .hljs-lisp .hljs-title, 427 | pre .hljs-subst { 428 | font-weight: normal 429 | } 430 | 431 | pre .hljs-class .hljs-title, 432 | pre .hljs-haskell .hljs-label, 433 | pre .hljs-tex .hljs-command { 434 | color: #458; 435 | font-weight: bold 436 | } 437 | 438 | pre .hljs-tag, 439 | pre .hljs-tag .hljs-title, 440 | pre .hljs-rules .hljs-property, 441 | pre .hljs-django .hljs-tag .hljs-keyword { 442 | color: #000080; 443 | font-weight: normal 444 | } 445 | 446 | pre .hljs-attribute, 447 | pre .hljs-variable, 448 | pre .hljs-instancevar, 449 | pre .hljs-lisp .hljs-body { 450 | color: #008080 451 | } 452 | 453 | pre .hljs-regexp { 454 | color: #B68 455 | } 456 | 457 | pre .hljs-class { 458 | color: #458; 459 | font-weight: bold 460 | } 461 | 462 | pre .hljs-symbol, 463 | pre .hljs-ruby .hljs-symbol .hljs-string, 464 | pre .hljs-ruby .hljs-symbol .hljs-keyword, 465 | pre .hljs-ruby .hljs-symbol .hljs-keymethods, 466 | pre .hljs-lisp .hljs-keyword, 467 | pre .hljs-tex .hljs-special, 468 | pre .hljs-input_number { 469 | color: #990073 470 | } 471 | 472 | pre .hljs-builtin, 473 | pre .hljs-constructor, 474 | pre .hljs-built_in, 475 | pre .hljs-lisp .hljs-title { 476 | color: #0086b3 477 | } 478 | 479 | pre .hljs-preprocessor, 480 | pre .hljs-pi, 481 | pre .hljs-doctype, 482 | pre .hljs-shebang, 483 | pre .hljs-cdata { 484 | color: #999; 485 | font-weight: bold 486 | } 487 | 488 | pre .hljs-deletion { 489 | background: #fdd 490 | } 491 | 492 | pre .hljs-addition { 493 | background: #dfd 494 | } 495 | 496 | pre .hljs-diff .hljs-change { 497 | background: #0086b3 498 | } 499 | 500 | pre .hljs-chunk { 501 | color: #aaa 502 | } 503 | 504 | pre .hljs-tex .hljs-formula { 505 | opacity: 0.5; 506 | } 507 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var React = require("react/addons"), 2 | Reflux = require("reflux"), 3 | Router = require("react-router"), 4 | { 5 | NotFoundRoute, 6 | Navigation, 7 | State, 8 | Link, 9 | Route, 10 | RouteHandler, 11 | DefaultRoute 12 | } = Router, 13 | osmAuth = require("osm-auth"), 14 | haversine = require("haversine"), 15 | xhr = require("xhr"), 16 | currency = require("./currency_symbols.json"), 17 | qs = require("querystring"); 18 | 19 | window.React = React; 20 | 21 | // Constants for API endpoints 22 | const API06 = "https://api.openstreetmap.org/api/0.6/", 23 | OVERPASS = "https://lz4.overpass-api.de/api/interpreter"; 24 | 25 | // Constants for our OAuth connection to OpenStreetMap. 26 | const OAUTH_CONSUMER_KEY = "VTdXpqeoRiraqICAoLN3MkPghHR5nEG8cKfwPUdw", 27 | OAUTH_SECRET = "ugrQJAmn1zgdn73rn9tKCRl6JQHaZkcen2z3JpAb"; 28 | 29 | // # Configuration 30 | // This is used to show certain nodes in the list: otherwise the ones 31 | // we're looking for would be crowded out by telephone poles etc. 32 | const KEYPAIR = { k: "amenity", v: "cafe" }, 33 | TAG = "cost:coffee", 34 | // The version string is added to changesets to let OSM know which 35 | // editor software is responsible for which changes. 36 | VERSION = "COFFEEDEX 2002", 37 | MBX = "pk.eyJ1IjoidG1jdyIsImEiOiIzczJRVGdRIn0.DKkDbTPnNUgHqTDBg7_zRQ", 38 | MAP = "tmcw.kbh273ee", 39 | PIN = "pin-l-cafe", 40 | LOC = "pin-s"; 41 | 42 | L.mapbox.accessToken = MBX; 43 | 44 | // # Parsing & Producing XML 45 | var a = nl => Array.prototype.slice.call(nl), 46 | attr = (n, k) => n.getAttribute(k), 47 | serializer = new XMLSerializer(); 48 | // Given an XML DOM in OSM format and an object of the form 49 | // 50 | // { k, v } 51 | // 52 | // Find all nodes with that key combination and return them 53 | // in the form 54 | // 55 | // { xml: Node, tags: {}, id: 'osm-id' } 56 | var parser = (xml, kv) => 57 | a(xml.getElementsByTagName("node")) 58 | .map(node => 59 | a(node.getElementsByTagName("tag")).reduce( 60 | (memo, tag) => { 61 | memo.tags[attr(tag, "k")] = attr(tag, "v"); 62 | return memo; 63 | }, 64 | { 65 | xml: node, 66 | tags: {}, 67 | id: attr(node, "id"), 68 | location: { 69 | latitude: parseFloat(attr(node, "lat")), 70 | longitude: parseFloat(attr(node, "lon")) 71 | } 72 | } 73 | ) 74 | ) 75 | .filter(node => node.tags[kv.k] === kv.v); 76 | var serialize = xml => 77 | serializer 78 | .serializeToString(xml) 79 | .replace('xmlns="http://www.w3.org/1999/xhtml"', ""); 80 | // Since we're building XML the hacky way by formatting strings, 81 | // we'll need to escape strings so that places like "Charlie's Shop" 82 | // don't make invalid XML. 83 | var escape = _ => 84 | _.replace(/&/g, "&") 85 | .replace(//g, ">") 87 | .replace(/"/g, """); 88 | // Generate the XML payload necessary to open a new changeset in OSM 89 | var changesetCreate = comment => ` 90 | 91 | 92 | `; 93 | // After the OSM changeset is opened, we need to send the changes: 94 | // this generates the necessary XML to add or update a specific 95 | // tag on a single node. 96 | var changesetChange = (node, tag, id) => { 97 | a(node.getElementsByTagName("tag")) 98 | .filter(tagElem => tagElem.getAttribute("k") === tag.k) 99 | .forEach(tagElem => node.removeChild(tagElem)); 100 | node.setAttribute("changeset", id); 101 | var newTag = node.appendChild(document.createElement("tag")); 102 | newTag.setAttribute("k", tag.k); 103 | newTag.setAttribute("v", tag.v); 104 | return ` 105 | ${serialize(node)} 106 | `; 107 | }; 108 | var sortDistance = location => (a, b) => 109 | haversine(location, a.location) - haversine(location, b.location); 110 | var queryOverpass = (center, kv, callback) => { 111 | const RADIUS = 0.1; 112 | var bbox = [ 113 | center.latitude - RADIUS, 114 | center.longitude - RADIUS, 115 | center.latitude + RADIUS, 116 | center.longitude + RADIUS 117 | ].join(","); 118 | var query = `[out:xml][timeout:25]; 119 | (node["${kv.k}"="${kv.v}"](${bbox});); out body; >; out skel qt;`; 120 | xhr({ uri: OVERPASS, method: "POST", body: query }, callback); 121 | }; 122 | var queryOverpassAll = callback => { 123 | var query = `[out:json][timeout:1000];(node["cost:coffee"];);out body; >; out skel qt;`; 124 | xhr({ uri: OVERPASS, method: "POST", body: query }, callback); 125 | }; 126 | 127 | // # Stores 128 | var locationStore = Reflux.createStore({ 129 | location: { latitude: 0, longitude: 0 }, 130 | getInitialState() { 131 | return this.location; 132 | }, 133 | init() { 134 | this.watcher = navigator.geolocation.watchPosition(res => { 135 | if (haversine(this.location, res.coords) > 10) { 136 | this.trigger(res.coords); 137 | } 138 | this.location = res.coords; 139 | }); 140 | } 141 | }); 142 | 143 | // The worldNode store stores only data for the WorldMap component: 144 | // instead of loading a list with [Overpass API](http://wiki.openstreetmap.org/wiki/Overpass_API) 145 | // and detail with API 0.6, this simply hits Overpass, and uses the easy-to-parse JSON output 146 | // instead of XML. 147 | // 148 | // We then transform Overpass's JSON encoding into [GeoJSON](http://geojson.org/) 149 | // so Mapbox.js can display the points. 150 | var worldNodeLoad = Reflux.createAction(); 151 | var worldNodeStore = Reflux.createStore({ 152 | nodes: null, 153 | getInitialState() { 154 | return this.nodes; 155 | }, 156 | init() { 157 | this.listenTo(worldNodeLoad, this.load); 158 | }, 159 | load() { 160 | queryOverpassAll((err, resp, json) => { 161 | if (err) return console.error(err); 162 | this.nodes = { 163 | type: "FeatureCollection", 164 | features: JSON.parse(json).elements.map(elem => { 165 | elem.tags.title = elem.tags.name || ""; 166 | elem.tags.description = elem.tags[TAG]; 167 | elem.tags["marker-symbol"] = "cafe"; 168 | elem.tags["marker-color"] = "#5a3410"; 169 | return { 170 | type: "Feature", 171 | properties: elem.tags, 172 | geometry: { type: "Point", coordinates: [elem.lon, elem.lat] } 173 | }; 174 | }) 175 | }; 176 | this.trigger(this.nodes); 177 | }); 178 | } 179 | }); 180 | 181 | // Here's where we store fully-formed OSM Nodes that correspond to matches. 182 | // These are listed with Overpass and then loaded in full with OSM API. 183 | // This two-step process imitates the ability to filter the OSM API - without 184 | // it, we'd have some very slow calls to the `/map/` endpoint, instead of 185 | // fast calls to the `/nodes` endpoint. 186 | var nodeLoad = Reflux.createAction(); 187 | var nodeSave = Reflux.createAction(); 188 | var nodeStore = Reflux.createStore({ 189 | nodes: {}, 190 | getInitialState() { 191 | return this.nodes; 192 | }, 193 | init() { 194 | this.listenTo(nodeLoad, this.load); 195 | this.listenTo(locationStore, this.load); 196 | this.listenTo(nodeSave, this.save); 197 | }, 198 | load(center) { 199 | queryOverpass(center, KEYPAIR, (err, resp, map) => { 200 | if (err) return console.error(err); 201 | this.loadNodes( 202 | parser(resp.responseXML, KEYPAIR) 203 | .sort(sortDistance(center)) 204 | .slice(0, 50) 205 | .map(n => n.id) 206 | ); 207 | }); 208 | }, 209 | loadNodes(ids) { 210 | ids = ids.filter(id => !this.nodes[id]); 211 | if (!ids.length) return this.trigger(this.nodes); 212 | xhr( 213 | { uri: `${API06}nodes/?nodes=${ids.join(",")}`, method: "GET" }, 214 | (err, resp, body) => { 215 | if (err) return console.error(err); 216 | parser(resp.responseXML, KEYPAIR).forEach(node => { 217 | if (!this.nodes[node.id]) this.nodes[node.id] = node; 218 | }); 219 | this.trigger(this.nodes); 220 | } 221 | ); 222 | }, 223 | save(res, price, currency) { 224 | const XMLHEADER = { header: { "Content-Type": "text/xml" } }; 225 | var xml = res.xml; 226 | var tag = { k: TAG, v: currency + price }; 227 | var comment = `Updating coffee price to ${currency} ${price} for ${res.tags.name}`; 228 | auth.xhr( 229 | { 230 | method: "PUT", 231 | prefix: false, 232 | options: XMLHEADER, 233 | content: changesetCreate(comment), 234 | path: `${API06}changeset/create` 235 | }, 236 | (err, id) => { 237 | if (err) return console.error(err); 238 | auth.xhr( 239 | { 240 | method: "POST", 241 | prefix: false, 242 | options: XMLHEADER, 243 | content: changesetChange(xml, tag, id), 244 | path: `${API06}changeset/${id}/upload` 245 | }, 246 | (err, res) => { 247 | auth.xhr( 248 | { 249 | method: "PUT", 250 | prefix: false, 251 | path: `${API06}changeset/${id}/close` 252 | }, 253 | (err, id) => { 254 | if (err) console.error(err); 255 | router.transitionTo("/success"); 256 | } 257 | ); 258 | } 259 | ); 260 | } 261 | ); 262 | } 263 | }); 264 | 265 | // osm-auth does the hard work of managing user authentication with 266 | // OpenStreetMap via the OAuth protocol. 267 | var auth = osmAuth({ 268 | oauth_consumer_key: OAUTH_CONSUMER_KEY, 269 | oauth_secret: OAUTH_SECRET, 270 | auto: false, 271 | landing: "index.html", 272 | singlepage: true 273 | }); 274 | 275 | // Here we store the user's logged-in / logged-out status so we can show 276 | // the authentication view instead of a list as an initial pageview. 277 | var userLogin = Reflux.createAction(); 278 | var userStore = Reflux.createStore({ 279 | user: null, 280 | init() { 281 | this.user = auth.authenticated(); 282 | this.listenTo(userLogin, this.login); 283 | }, 284 | getInitialState() { 285 | return this.user; 286 | }, 287 | login() { 288 | auth.authenticate((err, details) => { 289 | this.user = auth.authenticated(); 290 | this.trigger(this.user); 291 | }); 292 | } 293 | }); 294 | 295 | // # Components 296 | 297 | // A simple shout-out and log-in button that shoots a user into the OSM 298 | // oauth flow. 299 | var LogIn = React.createClass({ 300 | render() { 301 | /* jshint ignore:start */ 302 | return ( 303 |
304 |
305 | COFFEEDEX is built on OpenStreetMap and requires an OpenStreetMap 306 | account. 307 |
308 | 314 |
315 | ); 316 | /* jshint ignore:end */ 317 | } 318 | }); 319 | 320 | // A simple wrapper for a call to the [Mapbox Static Map API](https://www.mapbox.com/developers/api/static/) 321 | // that we use for editing pages: this gives a basic idea of where the coffee 322 | // shop is as well as a marker for your location. Helpful when there's 323 | // a Starbucks on every corner of an intersection. 324 | var StaticMap = React.createClass({ 325 | render() { 326 | return ( 327 | /* jshint ignore:start */ 328 | 339 | /* jshint ignore:end */ 340 | ); 341 | } 342 | }); 343 | 344 | var Page = React.createClass({ 345 | render() { 346 | return ( 347 | /* jshint ignore:start */ 348 |
349 |
350 | 351 |
352 |
353 | /* jshint ignore:end */ 354 | ); 355 | } 356 | }); 357 | 358 | var values = obj => Object.keys(obj).map(key => obj[key]); 359 | 360 | // A list of potential nodes for viewing and editing. 361 | var List = React.createClass({ 362 | // We use Reflux's `.connect` method to listen for changes in stores 363 | // and automatically call setState to use their data here. 364 | mixins: [ 365 | Reflux.connect(nodeStore, "nodes"), 366 | Reflux.connect(locationStore, "location"), 367 | Reflux.connect(userStore, "user") 368 | ], 369 | /* jshint ignore:start */ 370 | render() { 371 | return ( 372 |
373 |
374 |
375 |
376 | 382 |
383 |
384 |

COFFEEDEX

385 |

386 | how much does a cup of coffee for here cost, everywhere? 387 |

388 |
389 |
390 |
391 | {this.state.user ? ( 392 |
393 | {!values(this.state.nodes).length && ( 394 |
Loading...
395 | )} 396 | {values(this.state.nodes) 397 | .sort(sortDistance(this.state.location)) 398 | .map(res => ( 399 | 400 | ))} 401 |
402 | ) : ( 403 | 404 | )} 405 |
406 |
407 | 408 | World Map 409 | 410 | 411 | Help 412 | 413 |
414 |
415 |
416 | ); 417 | } 418 | /* jshint ignore:end */ 419 | }); 420 | 421 | // A single list item 422 | var Result = React.createClass({ 423 | render() { 424 | /* jshint ignore:start */ 425 | return ( 426 | 431 |
432 | {this.props.res.tags[TAG] ? ( 433 | this.props.res.tags[TAG] 434 | ) : ( 435 | 436 | )} 437 |
438 | {this.props.res.tags.name} 439 | 440 | ); 441 | /* jshint ignore:end */ 442 | } 443 | }); 444 | 445 | var parseCurrency = str => { 446 | var number = str.match(/[\d\.]+/), 447 | currency = str.match(/[^\d\.]+/); 448 | return { 449 | currency: currency || "$", 450 | price: parseFloat((number && number[0]) || 0) 451 | }; 452 | }; 453 | 454 | // This view is shown briefly after a user completes an edit. The user 455 | // can either click/tap to go back to the list, or it'll do that automatically 456 | // in 1 second. 457 | var Success = React.createClass({ 458 | mixins: [Navigation], 459 | componentDidMount() { 460 | setTimeout(() => { 461 | if (this.isMounted()) { 462 | this.transitionTo("list"); 463 | } 464 | }, 1000); 465 | }, 466 | /* jshint ignore:start */ 467 | render() { 468 | return ( 469 | 470 |

471 | Saved! 472 |

473 | 474 | ); 475 | } 476 | /* jshint ignore:end */ 477 | }); 478 | 479 | // The help page. Doesn't have any JavaScript functionality of its own - 480 | // this is static content. 481 | var Help = React.createClass({ 482 | /* jshint ignore:start */ 483 | render() { 484 | return ( 485 |
486 | 487 | home 488 | 489 |
490 |
491 |

492 | COFFEEDEX is a community project that aims to 493 | track the price of house coffee everywhere. 494 |

495 |

496 | The data is stored in OpenStreetMap, 497 | a free and open source map of the world, as tags on existing 498 | coffeehops. There are 150,000+. 499 |

500 |

501 | Maps in this application are ©{" "} 502 | Mapbox. 503 |

504 |

505 | COFFEEDEX data stored in OpenStreetMap is{" "} 506 | 507 | available under the ODbL license. 508 | 509 |

510 |

511 | This is also an open source project. You can view the source code, 512 | clone it, fork it, and make new things with it as inspiration or 513 | raw parts. 514 |

515 | 519 | COFFEEDEX on GitHub 520 | 521 |

522 | COFFEEDEX also works great 523 | on phones! Try it on your phone and add it to your iPhone home 524 | screen - it'll look even prettier. 525 |

526 |

FAQ

527 |
    528 |
  • 529 | Which coffee? This site tracks the price of{" "} 530 | house coffee for here. In many cases, that means a 12oz 531 | drip, but if all coffees are pour-overs or your country uses 532 | different standard size, the overriding rule is cheapest-here. 533 |
  • 534 |
535 |
536 |
537 |
538 | ); 539 | } 540 | /* jshint ignore:end */ 541 | }); 542 | 543 | // The WorldMap page. This uses the worldNodeLoad to show all tagged 544 | // nodes worldwide on an interactive Mapbox map. 545 | var WorldMap = React.createClass({ 546 | mixins: [Navigation, Reflux.connect(worldNodeStore, "nodes")], 547 | statics: { 548 | willTransitionTo(transition, params) { 549 | worldNodeLoad(); 550 | } 551 | }, 552 | /* jshint ignore:start */ 553 | componentDidMount() { 554 | this.map = L.mapbox.map(this.refs.map.getDOMNode(), MAP, { 555 | zoomControl: false 556 | }); 557 | if (this.state.nodes) this.map.featureLayer.setGeoJSON(this.state.nodes); 558 | }, 559 | componentDidUpdate() { 560 | if (this.state.nodes) this.map.featureLayer.setGeoJSON(this.state.nodes); 561 | }, 562 | render() { 563 | return ( 564 |
565 |
566 | {this.state.nodes && ( 567 |
568 | {this.state.nodes.features.length} prices worldwide 569 |
570 | )} 571 | 575 | home 576 | 577 |
578 | ); 579 | } 580 | /* jshint ignore:end */ 581 | }); 582 | 583 | // The editor. This allows users to view and edit tags on single result items. 584 | var Editor = React.createClass({ 585 | mixins: [ 586 | Reflux.listenTo(nodeStore, "onNodeLoad", "onNodeLoad"), 587 | Reflux.connect(locationStore, "location"), 588 | State, 589 | React.addons.LinkedStateMixin 590 | ], 591 | onNodeLoad(nodes) { 592 | var node = nodes[this.getParams().osmId]; 593 | if (node) { 594 | if (node.tags[TAG]) { 595 | var currency = parseCurrency(node.tags[TAG]); 596 | this.setState({ 597 | currency: currency.currency, 598 | price: currency.price, 599 | node: node 600 | }); 601 | } else { 602 | this.setState({ node: node }); 603 | } 604 | } 605 | }, 606 | getInitialState() { 607 | return { 608 | currency: "$", 609 | price: 0 610 | }; 611 | }, 612 | // Before this view is displayed, we make sure that the node it'll 613 | // show will be loaded soon. 614 | statics: { 615 | willTransitionTo(transition, params) { 616 | nodeStore.loadNodes([params.osmId]); 617 | } 618 | }, 619 | save(e) { 620 | e.preventDefault(); 621 | var node = this.state.node; 622 | nodeSave(node, this.state.price, this.state.currency); 623 | }, 624 | render() { 625 | var node = this.state.node; 626 | /* jshint ignore:start */ 627 | if (!node) return
Loading...
; 628 | return ( 629 |
630 | 631 | home 632 | 633 | 634 |
635 |
636 |
how much for a cup of joe at
637 |

{node.tags.name}

638 |
639 |
640 |
641 | 651 | 656 |
657 | 662 | Save 663 | 664 |
665 |
666 |
667 | ); 668 | /* jshint ignore:end */ 669 | } 670 | }); 671 | 672 | // Our router. This manages what URLs mean and where Links can go. 673 | var routes = ( 674 | /* jshint ignore:start */ 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | /* jshint ignore:end */ 683 | ); 684 | 685 | var router = Router.create({ routes }); 686 | 687 | // This is a little dirty: the router will rewrite paths it doesn't know, 688 | // including the path we desperately need to complete the OAuth dance. 689 | // So before booting it up, we notice if we need to bootstrap an oauth_token, 690 | // and if so, we do that before starting the application. 691 | if (location.search && !auth.authenticated()) { 692 | var oauth_token = qs.parse(location.search.replace("?", "")).oauth_token; 693 | auth.bootstrapToken(oauth_token, (err, res) => { 694 | userStore.user = true; 695 | userStore.trigger(userStore.user); 696 | router.run(Handler => { 697 | /* jshint ignore:start */ 698 | React.render(, document.body); 699 | /* jshint ignore:end */ 700 | }); 701 | }); 702 | } else { 703 | router.run(Handler => { 704 | /* jshint ignore:start */ 705 | React.render(, document.body); 706 | /* jshint ignore:end */ 707 | }); 708 | } 709 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | index.js 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 |
    15 | 16 |
  • 17 |
    18 |

    index.js

    19 |
    20 |
  • 21 | 22 | 23 | 24 |
  • 25 |
    26 | 27 |
    28 | 29 |
    30 | 31 |
    32 | 33 |
    var React = require('react/addons'),
     34 |   Reflux = require('reflux'),
     35 |   Router = require('react-router'),
     36 |   { NotFoundRoute, Navigation, State, Link, Route, RouteHandler, DefaultRoute } = Router,
     37 |   osmAuth = require('osm-auth'),
     38 |   haversine = require('haversine'),
     39 |   xhr = require('xhr'),
     40 |   currency = require('./currency_symbols.json'),
     41 |   qs = require('querystring');
     42 | 
     43 | window.React = React;
    44 | 45 |
  • 46 | 47 | 48 |
  • 49 |
    50 | 51 |
    52 | 53 |
    54 |

    Constants for API endpoints

    55 | 56 |
    57 | 58 |
    const API06 = 'http://api.openstreetmap.org/api/0.6/',
     59 |   OVERPASS = 'http://overpass-api.de/api/interpreter';
    60 | 61 |
  • 62 | 63 | 64 |
  • 65 |
    66 | 67 |
    68 | 69 |
    70 |

    Constants for our OAuth connection to OpenStreetMap.

    71 | 72 |
    73 | 74 |
    const OAUTH_CONSUMER_KEY = 'VTdXpqeoRiraqICAoLN3MkPghHR5nEG8cKfwPUdw',
     75 |   OAUTH_SECRET = 'ugrQJAmn1zgdn73rn9tKCRl6JQHaZkcen2z3JpAb';
    76 | 77 |
  • 78 | 79 | 80 |
  • 81 |
    82 | 83 |
    84 | 85 |
    86 |

    Configuration

    87 |

    This is used to show certain nodes in the list: otherwise the ones 88 | we’re looking for would be crowded out by telephone poles etc.

    89 | 90 |
    91 | 92 |
    const KEYPAIR = { k: 'amenity', v: 'cafe' },
     93 |   TAG = 'cost:coffee',
    94 | 95 |
  • 96 | 97 | 98 |
  • 99 |
    100 | 101 |
    102 | 103 |
    104 |

    The version string is added to changesets to let OSM know which 105 | editor software is responsible for which changes.

    106 | 107 |
    108 | 109 |
      VERSION = 'COFFEEDEX 2002',
    110 |   MBX = 'pk.eyJ1IjoidG1jdyIsImEiOiIzczJRVGdRIn0.DKkDbTPnNUgHqTDBg7_zRQ',
    111 |   MAP = 'tmcw.kbh273ee',
    112 |   PIN = 'pin-l-cafe',
    113 |   LOC = 'pin-s';
    114 | 
    115 | L.mapbox.accessToken = MBX;
    116 | 117 |
  • 118 | 119 | 120 |
  • 121 |
    122 | 123 |
    124 | 125 |
    126 |

    Parsing & Producing XML

    127 | 128 |
    129 | 130 |
    var a = (nl) => Array.prototype.slice.call(nl),
    131 |   attr = (n, k) => n.getAttribute(k),
    132 |   serializer = new XMLSerializer();
    133 | 134 |
  • 135 | 136 | 137 |
  • 138 |
    139 | 140 |
    141 | 142 |
    143 |

    Given an XML DOM in OSM format and an object of the form

    144 |
    { k, v }
    145 | 

    Find all nodes with that key combination and return them 146 | in the form

    147 |
    { xml: Node, tags: {}, id: 'osm-id' }
    148 | 
    149 |
    150 | 151 |
    var parser = (xml, kv) =>
    152 |   a(xml.getElementsByTagName('node')).map(node =>
    153 |     a(node.getElementsByTagName('tag')).reduce((memo, tag) => {
    154 |       memo.tags[attr(tag, 'k')] = attr(tag, 'v'); return memo;
    155 |     }, {
    156 |       xml: node, tags: {}, id: attr(node, 'id'),
    157 |       location: {
    158 |         latitude: parseFloat(attr(node, 'lat')),
    159 |         longitude: parseFloat(attr(node, 'lon'))
    160 |       }
    161 |     }))
    162 |     .filter(node => node.tags[kv.k] === kv.v);
    163 | var serialize = (xml) => serializer.serializeToString(xml)
    164 |   .replace('xmlns="http://www.w3.org/1999/xhtml"', '');
    165 | 166 |
  • 167 | 168 | 169 |
  • 170 |
    171 | 172 |
    173 | 174 |
    175 |

    Since we’re building XML the hacky way by formatting strings, 176 | we’ll need to escape strings so that places like “Charlie’s Shop” 177 | don’t make invalid XML.

    178 | 179 |
    180 | 181 |
    var escape = _ => _.replace(/&/g, '&')
    182 |   .replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    183 | 184 |
  • 185 | 186 | 187 |
  • 188 |
    189 | 190 |
    191 | 192 |
    193 |

    Generate the XML payload necessary to open a new changeset in OSM

    194 | 195 |
    196 | 197 |
    var changesetCreate = (comment) => `<osm><changeset>
    198 |     <tag k="created_by" v="${VERSION}" />
    199 |     <tag k="comment" v="${escape(comment)}" />
    200 |   </changeset></osm>`;
    201 | 
    202 | 203 |
  • 204 | 205 | 206 |
  • 207 |
    208 | 209 |
    210 | 211 |
    212 |

    After the OSM changeset is opened, we need to send the changes: 213 | this generates the necessary XML to add or update a specific 214 | tag on a single node.

    215 | 216 |
    217 | 218 |
    var changesetChange = (node, tag, id) => {
    219 |   a(node.getElementsByTagName('tag'))
    220 |     .filter(tagElem => tagElem.getAttribute('k') === tag.k)
    221 |     .forEach(tagElem =>  node.removeChild(tagElem));
    222 |   node.setAttribute('changeset', id);
    223 |   var newTag = node.appendChild(document.createElement('tag'));
    224 |   newTag.setAttribute('k', tag.k); newTag.setAttribute('v', tag.v);
    225 |   return `<osmChange version="0.3" generator="${VERSION}">
    226 |     <modify>${serialize(node)}</modify>
    227 |     </osmChange>`;
    228 | };
    229 | var sortDistance = (location) =>
    230 |   (a, b) => haversine(location, a.location) - haversine(location, b.location);
    231 | var queryOverpass = (center, kv, callback) => {
    232 |   const RADIUS = 0.1;
    233 |   var bbox = [
    234 |     center.latitude - RADIUS, center.longitude - RADIUS,
    235 |     center.latitude + RADIUS, center.longitude + RADIUS
    236 |   ].join(',');
    237 |   var query = `[out:xml][timeout:25];
    238 |   (node["${kv.k}"="${kv.v}"](${bbox});); out body; >; out skel qt;`;
    239 |   xhr({ uri: OVERPASS, method: 'POST', body: query }, callback);
    240 | };
    241 | var queryOverpassAll = (callback) => {
    242 |   var query = `[out:json][timeout:1000];(node["cost:coffee"];);out body; >; out skel qt;`;
    243 |   xhr({ uri: OVERPASS, method: 'POST', body: query }, callback);
    244 | };
    245 | 246 |
  • 247 | 248 | 249 |
  • 250 |
    251 | 252 |
    253 | 254 |
    255 |

    Stores

    256 | 257 |
    258 | 259 |
    var locationStore = Reflux.createStore({
    260 |   location: { latitude: 0, longitude: 0 },
    261 |   getInitialState() { return this.location; },
    262 |   init() {
    263 |     this.watcher = navigator.geolocation.watchPosition(res => {
    264 |       if (haversine(this.location, res.coords) > 10) {
    265 |         this.trigger(res.coords);
    266 |       }
    267 |       this.location = res.coords;
    268 |     });
    269 |   }
    270 | });
    271 | 272 |
  • 273 | 274 | 275 |
  • 276 |
    277 | 278 |
    279 | 280 |
    281 |

    The worldNode store stores only data for the WorldMap component: 282 | instead of loading a list with Overpass API 283 | and detail with API 0.6, this simply hits Overpass, and uses the easy-to-parse JSON output 284 | instead of XML.

    285 |

    We then transform Overpass’s JSON encoding into GeoJSON 286 | so Mapbox.js can display the points.

    287 | 288 |
    289 | 290 |
    var worldNodeLoad = Reflux.createAction();
    291 | var worldNodeStore = Reflux.createStore({
    292 |   nodes: null,
    293 |   getInitialState() { return this.nodes; },
    294 |   init() { this.listenTo(worldNodeLoad, this.load); },
    295 |   load() {
    296 |     queryOverpassAll((err, resp, json) => {
    297 |       if (err) return console.error(err);
    298 |       this.nodes = {
    299 |         type: 'FeatureCollection',
    300 |         features: JSON.parse(json).elements.map((elem) => {
    301 |           elem.tags.title = elem.tags.name || '';
    302 |           elem.tags.description = elem.tags[TAG];
    303 |           elem.tags['marker-symbol'] = 'cafe';
    304 |           elem.tags['marker-color'] = '#5a3410';
    305 |           return {
    306 |             type: 'Feature',
    307 |             properties: elem.tags,
    308 |             geometry: { type: 'Point', coordinates: [elem.lon, elem.lat]  }
    309 |           };
    310 |         })
    311 |       };
    312 |       this.trigger(this.nodes);
    313 |     });
    314 |   }
    315 | });
    316 | 317 |
  • 318 | 319 | 320 |
  • 321 |
    322 | 323 |
    324 | 325 |
    326 |

    Here’s where we store fully-formed OSM Nodes that correspond to matches. 327 | These are listed with Overpass and then loaded in full with OSM API. 328 | This two-step process imitates the ability to filter the OSM API - without 329 | it, we’d have some very slow calls to the /map/ endpoint, instead of 330 | fast calls to the /nodes endpoint.

    331 | 332 |
    333 | 334 |
    var nodeLoad = Reflux.createAction();
    335 | var nodeSave = Reflux.createAction();
    336 | var nodeStore = Reflux.createStore({
    337 |   nodes: {},
    338 |   getInitialState() { return this.nodes; },
    339 |   init() {
    340 |     this.listenTo(nodeLoad, this.load);
    341 |     this.listenTo(locationStore, this.load);
    342 |     this.listenTo(nodeSave, this.save);
    343 |   },
    344 |   load(center) {
    345 |     queryOverpass(center, KEYPAIR, (err, resp, map) => {
    346 |       if (err) return console.error(err);
    347 |       parser(resp.responseXML, KEYPAIR)
    348 |         .sort(sortDistance(center))
    349 |         .slice(0, 50)
    350 |         .map(node => node.id).forEach(id => this.loadNodes([id]));
    351 |     });
    352 |   },
    353 |   loadNodes(ids) {
    354 |     ids = ids.filter(id => !this.nodes[id]);
    355 |     if (!ids.length) return this.trigger(this.nodes);
    356 |     xhr({ uri: `${API06}nodes/?nodes=${ids.join(',')}`, method: 'GET' }, (err, resp, body) => {
    357 |       parser(resp.responseXML, KEYPAIR).forEach(node => {
    358 |         if (!this.nodes[node.id]) this.nodes[node.id] = node;
    359 |       });
    360 |       this.trigger(this.nodes);
    361 |     });
    362 |   },
    363 |   save(res, price, currency) {
    364 |     const XMLHEADER = { header: { 'Content-Type': 'text/xml' } };
    365 |     var xml = res.xml;
    366 |     var tag = { k: TAG, v: currency + price };
    367 |     var comment = `Updating coffee price to ${currency} ${price} for ${res.tags.name}`;
    368 |     auth.xhr({ method: 'PUT', prefix: false, options: XMLHEADER,
    369 |       content: changesetCreate(comment),
    370 |       path: `${API06}changeset/create`
    371 |     }, (err, id) => {
    372 |       if (err) return console.error(err);
    373 |       auth.xhr({ method: 'POST', prefix: false, options: XMLHEADER,
    374 |         content: changesetChange(xml, tag, id),
    375 |         path: `${API06}changeset/${id}/upload`,
    376 |       }, (err, res) => {
    377 |         auth.xhr({ method: 'PUT', prefix: false,
    378 |           path: `${API06}changeset/${id}/close`
    379 |         }, (err, id) => {
    380 |             if (err) console.error(err);
    381 |             router.transitionTo('/success');
    382 |         });
    383 |       });
    384 |     });
    385 |   }
    386 | });
    387 | 388 |
  • 389 | 390 | 391 |
  • 392 |
    393 | 394 |
    395 | 396 |
    397 |

    osm-auth does the hard work of managing user authentication with 398 | OpenStreetMap via the OAuth protocol.

    399 | 400 |
    401 | 402 |
    var auth = osmAuth({
    403 |   oauth_consumer_key: OAUTH_CONSUMER_KEY,
    404 |   oauth_secret: OAUTH_SECRET,
    405 |   auto: false,
    406 |   landing: 'index.html',
    407 |   singlepage: true
    408 | });
    409 | 410 |
  • 411 | 412 | 413 |
  • 414 |
    415 | 416 |
    417 | 418 |
    419 |

    Here we store the user’s logged-in / logged-out status so we can show 420 | the authentication view instead of a list as an initial pageview.

    421 | 422 |
    423 | 424 |
    var userLogin = Reflux.createAction();
    425 | var userStore = Reflux.createStore({
    426 |   user: null,
    427 |   init() {
    428 |     this.user = auth.authenticated();
    429 |     this.listenTo(userLogin, this.login);
    430 |   },
    431 |   getInitialState() {
    432 |     return this.user;
    433 |   },
    434 |   login() {
    435 |     auth.authenticate((err, details) => {
    436 |       this.user = auth.authenticated();
    437 |       this.trigger(this.user);
    438 |     });
    439 |   }
    440 | });
    441 | 442 |
  • 443 | 444 | 445 |
  • 446 |
    447 | 448 |
    449 | 450 |
    451 |

    Components

    452 | 453 |
    454 | 455 |
  • 456 | 457 | 458 |
  • 459 |
    460 | 461 |
    462 | 463 |
    464 |

    A simple shout-out and log-in button that shoots a user into the OSM 465 | oauth flow.

    466 | 467 |
    468 | 469 |
    var LogIn = React.createClass({
    470 |   render() {
    471 |     /* jshint ignore:start */
    472 |     return (<div className='pad2'>
    473 |         <div className='pad1 space-bottom1'>
    474 |           COFFEEDEX is built on OpenStreetMap and requires an OpenStreetMap account.
    475 |         </div>
    476 |         <button
    477 |           onClick={userLogin}
    478 |           className='button col12 fill-green icon account'>Log in to OpenStreetMap</button>
    479 |       </div>
    480 |     );
    481 |     /* jshint ignore:end */
    482 |   }
    483 | });
    484 | 
    485 | 
    486 | 487 |
  • 488 | 489 | 490 |
  • 491 |
    492 | 493 |
    494 | 495 |
    496 |

    A simple wrapper for a call to the Mapbox Static Map API 497 | that we use for editing pages: this gives a basic idea of where the coffee 498 | shop is as well as a marker for your location. Helpful when there’s 499 | a Starbucks on every corner of an intersection.

    500 | 501 |
    502 | 503 |
    var StaticMap = React.createClass({
    504 |   render() {
    505 |     return (
    506 |       /* jshint ignore:start */
    507 |       <img src={`https://api.tiles.mapbox.com/v4/${MAP}/` +
    508 |         `${PIN}(${this.props.location.longitude},${this.props.location.latitude}),` +
    509 |         (this.props.self ? `${LOC}(${this.props.self.longitude},${this.props.self.latitude})` : '') +
    510 |         `/${this.props.location.longitude},${this.props.location.latitude}` +
    511 |         `,14/300x200@2x.png?access_token=${MBX}`} />
    512 |       /* jshint ignore:end */
    513 |     );
    514 |   }
    515 | });
    516 | 
    517 | var Page = React.createClass({
    518 |   render() {
    519 |     return (
    520 |       /* jshint ignore:start */
    521 |       <div className='margin3 col6'>
    522 |         <div className='col12'>
    523 |           <RouteHandler/>
    524 |         </div>
    525 |       </div>
    526 |       /* jshint ignore:end */
    527 |     );
    528 |   }
    529 | });
    530 | 
    531 | var values = obj => Object.keys(obj).map(key => obj[key]);
    532 | 
    533 | 
    534 | 535 |
  • 536 | 537 | 538 |
  • 539 |
    540 | 541 |
    542 | 543 |
    544 |

    A list of potential nodes for viewing and editing.

    545 | 546 |
    547 | 548 |
    var List = React.createClass({
    549 | 550 |
  • 551 | 552 | 553 |
  • 554 |
    555 | 556 |
    557 | 558 |
    559 |

    We use Reflux’s .connect method to listen for changes in stores 560 | and automatically call setState to use their data here.

    561 | 562 |
    563 | 564 |
      mixins: [
    565 |     Reflux.connect(nodeStore, 'nodes'),
    566 |     Reflux.connect(locationStore, 'location'),
    567 |     Reflux.connect(userStore, 'user')],
    568 |   /* jshint ignore:start */
    569 |   render() {
    570 |     return (
    571 |     <div>
    572 |       <div className='clearfix col12'>
    573 |         <div className='pad2 fill-darken0 clearfix'>
    574 |           <div className='col4'>
    575 |             <img width={300/2} height={230/2}
    576 |               className='inline' src='assets/logo_inverted.png' />
    577 |           </div>
    578 |           <div className='col8 pad2y pad1x'>
    579 |             <h3>COFFEEDEX</h3>
    580 |             <p className='italic'>how much does a cup of coffee for here cost, everywhere?</p>
    581 |           </div>
    582 |         </div>
    583 |       </div>
    584 |       {this.state.user ?
    585 |         <div className='pad2'>
    586 |           {!values(this.state.nodes).length && <div className='pad4 center'>
    587 |             Loading...
    588 |           </div>}
    589 |           <React.addons.CSSTransitionGroup transitionName="t-fade">
    590 |           {values(this.state.nodes)
    591 |             .sort(sortDistance(this.state.location))
    592 |             .map(res => <Result key={res.id} res={res} />)}
    593 |           </React.addons.CSSTransitionGroup>
    594 |         </div> :
    595 |       <LogIn />}
    596 |       <div className='center dark space-bottom1'>
    597 |         <div className='pill space-top1'>
    598 |           <Link
    599 |             className='button stroke quiet icon globe'
    600 |             to='world_map'>World Map</Link>
    601 |           <Link
    602 |             className='button stroke quiet'
    603 |             to='help'>Help</Link>
    604 |         </div>
    605 |       </div>
    606 |     </div>);
    607 |   }
    608 |   /* jshint ignore:end */
    609 | });
    610 | 
    611 | 
    612 | 613 |
  • 614 | 615 | 616 |
  • 617 |
    618 | 619 |
    620 | 621 |
    622 |

    A single list item

    623 | 624 |
    625 | 626 |
    var Result = React.createClass({
    627 |   render() {
    628 |     /* jshint ignore:start */
    629 |     return <Link to='editor'
    630 |       params={{ osmId: this.props.res.id }}
    631 |       className='pad1 col12 clearfix fill-coffee space-bottom1'>
    632 |       <div className='price-tag round'>
    633 |         {this.props.res.tags[TAG] ?
    634 |           this.props.res.tags[TAG] : <span className='icon pencil'></span>}
    635 |       </div>
    636 |       <strong>{this.props.res.tags.name}</strong>
    637 |     </Link>;
    638 |     /* jshint ignore:end */
    639 |   }
    640 | });
    641 | 
    642 | var parseCurrency = str => {
    643 |   var number = str.match(/[\d\.]+/), currency = str.match(/[^\d\.]+/);
    644 |   return {
    645 |     currency: currency || '$',
    646 |     price: parseFloat((number && number[0]) || 0)
    647 |   };
    648 | };
    649 | 650 |
  • 651 | 652 | 653 |
  • 654 |
    655 | 656 |
    657 | 658 |
    659 |

    This view is shown briefly after a user completes an edit. The user 660 | can either click/tap to go back to the list, or it’ll do that automatically 661 | in 1 second.

    662 | 663 |
    664 | 665 |
    var Success = React.createClass({
    666 |   mixins: [Navigation],
    667 |   componentDidMount() {
    668 |     setTimeout(() => {
    669 |       if (this.isMounted()) {
    670 |         this.transitionTo('list');
    671 |       }
    672 |     }, 1000);
    673 |   },
    674 |   /* jshint ignore:start */
    675 |   render() {
    676 |     return <Link to='list' className='col12 center pad4'>
    677 |       <h2><span className='big icon check'></span> Saved!</h2>
    678 |     </Link>;
    679 |   }
    680 |   /* jshint ignore:end */
    681 | });
    682 | 683 |
  • 684 | 685 | 686 |
  • 687 |
    688 | 689 |
    690 | 691 |
    692 |

    The help page. Doesn’t have any JavaScript functionality of its own - 693 | this is static content.

    694 | 695 |
    696 | 697 |
    var Help = React.createClass({
    698 |   /* jshint ignore:start */
    699 |   render() {
    700 |     return <div>
    701 |       <Link
    702 |         to='list'
    703 |         className='home icon button fill-darken2 col12'>home</Link>
    704 |       <div className='pad1y'>
    705 |         <div className='round fill-lighten0 pad2 dark'>
    706 |           <p><strong>COFFEEDEX</strong> is a community project that aims to track the price of house coffee everywhere.</p>
    707 |           <p>The data is stored in <a href='http://osm.org/'>OpenStreetMap</a>, a free and open source map of the world, as tags on existing coffeehops. There are 150,000+.</p>
    708 |           <p>Maps in this application are &copy; <a href='http://mapbox.com/'>Mapbox</a>.</p>
    709 |           <p>COFFEEDEX data stored in OpenStreetMap is <a href='http://www.openstreetmap.org/copyright'>available under the ODbL license.</a></p>
    710 |           <p>This is also an open source project. You can view the source code, clone it, fork it, and make new things with it as inspiration or raw parts.</p>
    711 |           <a className='button stroke icon github col12 space-bottom1' href='http://github.com/tmcw/coffeedex'>COFFEEDEX on GitHub</a>
    712 |           <p><span className='icon mobile'></span> COFFEEDEX also works great on phones! Try it on your phone and add it to your iPhone home screen - it'll look even prettier.</p>
    713 |           <h2>FAQ</h2>
    714 |           <ul>
    715 |             <li><strong>Which coffee?</strong> This site tracks the price of <em>house coffee</em> for here. In many cases, that means a 12oz drip, but if all coffees are pour-overs or your country uses different standard size, the overriding rule is cheapest-here.</li>
    716 |           </ul>
    717 |         </div>
    718 |       </div>
    719 |     </div>;
    720 |   }
    721 |   /* jshint ignore:end */
    722 | });
    723 | 724 |
  • 725 | 726 | 727 |
  • 728 |
    729 | 730 |
    731 | 732 |
    733 |

    The WorldMap page. This uses the worldNodeLoad to show all tagged 734 | nodes worldwide on an interactive Mapbox map.

    735 | 736 |
    737 | 738 |
    var WorldMap = React.createClass({
    739 |   mixins: [Navigation, Reflux.connect(worldNodeStore, 'nodes')],
    740 |   statics: {
    741 |     willTransitionTo(transition, params) {
    742 |       worldNodeLoad();
    743 |     }
    744 |   },
    745 |   /* jshint ignore:start */
    746 |   componentDidMount() {
    747 |     this.map = L.mapbox.map(this.refs.map.getDOMNode(), MAP, {
    748 |       zoomControl: false
    749 |     });
    750 |     if (this.state.nodes) this.map.featureLayer.setGeoJSON(this.state.nodes);
    751 |   },
    752 |   componentDidUpdate() {
    753 |     if (this.state.nodes) this.map.featureLayer.setGeoJSON(this.state.nodes);
    754 |   },
    755 |   render() {
    756 |     return <div>
    757 |       <div ref='map' className='pin-top pin-bottom' id='map'></div>
    758 |       <Link
    759 |         to='list'
    760 |         className='home icon button fill-navy dark pin-top unround col12'>home</Link>
    761 |     </div>;
    762 |   }
    763 |   /* jshint ignore:end */
    764 | });
    765 | 766 |
  • 767 | 768 | 769 |
  • 770 |
    771 | 772 |
    773 | 774 |
    775 |

    The editor. This allows users to view and edit tags on single result items.

    776 | 777 |
    778 | 779 |
    var Editor = React.createClass({
    780 |   mixins: [
    781 |     Reflux.listenTo(nodeStore, 'onNodeLoad', 'onNodeLoad'),
    782 |     Reflux.connect(locationStore, 'location'),
    783 |     State, React.addons.LinkedStateMixin],
    784 |   onNodeLoad(nodes) {
    785 |     var node = nodes[this.getParams().osmId];
    786 |     if (node) {
    787 |       if (node.tags[TAG]) {
    788 |         var currency = parseCurrency(node.tags[TAG]);
    789 |         this.setState({
    790 |           currency: currency.currency,
    791 |           price: currency.price,
    792 |           node: node
    793 |         });
    794 |       } else {
    795 |         this.setState({ node: node });
    796 |       }
    797 |     }
    798 |   },
    799 |   getInitialState() {
    800 |     return {
    801 |       currency: '$',
    802 |       price: 0
    803 |     };
    804 |   },
    805 | 806 |
  • 807 | 808 | 809 |
  • 810 |
    811 | 812 |
    813 | 814 |
    815 |

    Before this view is displayed, we make sure that the node it’ll 816 | show will be loaded soon.

    817 | 818 |
    819 | 820 |
      statics: {
    821 |     willTransitionTo(transition, params) {
    822 |       nodeStore.loadNodes([params.osmId]);
    823 |     },
    824 |   },
    825 |   save(e) {
    826 |     e.preventDefault();
    827 |     var node = this.state.node;
    828 |     nodeSave(node, this.state.price, this.state.currency);
    829 |   },
    830 |   render() {
    831 |     var node = this.state.node;
    832 |     /* jshint ignore:start */
    833 |     if (!node) return <div className='pad4 center'>
    834 |       Loading...
    835 |     </div>;
    836 |     return <div className='col12'>
    837 |       <Link
    838 |         to='list'
    839 |         className='home icon button fill-darken0 unround col12'>home</Link>
    840 |       <StaticMap location={node.location} self={this.state.location} />
    841 |       <div className='pad1 col12 clearfix'>
    842 |         <div className='col12'>
    843 |           <div className='center'>
    844 |             how much for a cup of joe at
    845 |           </div>
    846 |           <h1 className='center'>
    847 |             {node.tags.name}
    848 |           </h1>
    849 |         </div>
    850 |         <div className='limit-mobile'>
    851 |           <div className='col12 clearfix space-bottom1'>
    852 |             <select
    853 |               valueLink={this.linkState('currency')}
    854 |               className='coffee-select'>
    855 |               {currency.map(c => <option key={c[0]} value={c[0]}>{c[1]}</option>)}
    856 |             </select>
    857 |             <input valueLink={this.linkState('price')}
    858 |               className='coffee-input' type='number' />
    859 |           </div>
    860 |           <a href='#'
    861 |             onClick={this.save}
    862 |           className='fill-darken1 button col12 icon plus pad1 unround'>Save</a>
    863 |         </div>
    864 |       </div>
    865 |     </div>;
    866 |     /* jshint ignore:end */
    867 |   }
    868 | });
    869 | 870 |
  • 871 | 872 | 873 |
  • 874 |
    875 | 876 |
    877 | 878 |
    879 |

    Our router. This manages what URLs mean and where Links can go.

    880 | 881 |
    882 | 883 |
    var routes = (
    884 |   /* jshint ignore:start */
    885 |   <Route handler={Page} path='/'>
    886 |     <DefaultRoute name='list' handler={List} />
    887 |     <Route name='world_map' path='/world_map' handler={WorldMap} />
    888 |     <Route name='success' path='/success' handler={Success} />
    889 |     <Route name='help' path='/help' handler={Help} />
    890 |     <Route name='editor' path='/edit/:osmId' handler={Editor} />
    891 |   </Route>
    892 |   /* jshint ignore:end */
    893 | );
    894 | 
    895 | var router = Router.create({ routes });
    896 | 
    897 | 
    898 | 899 |
  • 900 | 901 | 902 |
  • 903 |
    904 | 905 |
    906 | 907 |
    908 |

    This is a little dirty: the router will rewrite paths it doesn’t know, 909 | including the path we desperately need to complete the OAuth dance. 910 | So before booting it up, we notice if we need to bootstrap an oauth_token, 911 | and if so, we do that before starting the application.

    912 | 913 |
    914 | 915 |
    if (location.search && !auth.authenticated()) {
    916 |   var oauth_token = qs.parse(location.search.replace('?', '')).oauth_token;
    917 |   auth.bootstrapToken(oauth_token, (err, res) => {
    918 |     userStore.user = true;
    919 |     userStore.trigger(userStore.user);
    920 |     router.run(Handler => {
    921 |       /* jshint ignore:start */
    922 |       React.render(<Handler/>, document.body);
    923 |       /* jshint ignore:end */
    924 |     });
    925 |   });
    926 | } else {
    927 |   router.run(Handler => {
    928 |     /* jshint ignore:start */
    929 |     React.render(<Handler/>, document.body);
    930 |     /* jshint ignore:end */
    931 |   });
    932 | }
    933 | 
    934 | 
    935 | 936 |
  • 937 | 938 |
939 |
940 | 941 | 942 | --------------------------------------------------------------------------------