├── screen-shot.png ├── src ├── index.css ├── index.js ├── helper.js ├── config.js ├── data.js └── tube.js ├── .gitignore ├── package.json ├── public └── index.html └── README.md /screen-shot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stereobooster/d3-tube/master/screen-shot.png -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Tube from './tube' 2 | import config from './config' 3 | import data from './data' 4 | import './index.css' 5 | 6 | const tube = new Tube() 7 | 8 | config.transit = data; 9 | config.transit.labels = 1; 10 | config.transit.legend = 1; 11 | tube.render(config) 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-tube", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://stereobooster.github.io/d3-tube", 6 | "devDependencies": { 7 | "react-scripts": "0.9.0" 8 | }, 9 | "dependencies": { 10 | "d3": "3.4.13", 11 | "jquery": "1.11.1", 12 | "jquery.panzoom": "^3.2.2", 13 | "modernizr": "^3.3.1", 14 | "underscore": "1.6.0" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test --env=jsdom", 20 | "eject": "react-scripts eject", 21 | "predeploy": "npm run build", 22 | "deploy": "gh-pages -d build" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![screen-shot](screen-shot.png) 2 | 3 | # D3 tube map 4 | 5 | D3 implementation of tube map. Component extracted from [beefoo/memory-underground](https://github.com/beefoo/memory-underground). All credits go to @beefoo. [Example](https://stereobooster.github.io/d3-tube/). 6 | 7 | ## TODO 8 | 9 | - remove jquery dependency 10 | - remove underscore dependency 11 | - update D3 12 | - use D3 [pan zoom](https://bl.ocks.org/mbostock/7ec977c95910dd026812) instead of jquery plugin (or simple [d3.zoom](https://coderwall.com/p/psogia/simplest-way-to-add-zoom-pan-on-d3-js)) 13 | - clean up code and document options 14 | - implement as separate module and publish as npm package 15 | 16 | ## Other implemetations 17 | 18 | - [d3-tube-map](https://github.com/johnwalley/d3-tube-map) 19 | - [Visualizing London Tube map](https://bl.ocks.org/nicola/69730fc4180246b0d56d) 20 | - [Visualizing Voronoi diagram of London Tubemap](https://github.com/nicola/tubemaps/tree/master/examples/voronoi) 21 | - [travel-time-tube-d3](https://randometc.github.io/travel-time-tube-d3/) 22 | - [Mapping the DC Metro](https://www.mapbox.com/blog/dc-metro-map/) 23 | 24 | ## Development 25 | 26 | ```sh 27 | git clone https://github.com/stereobooster/d3-tube.git 28 | cd d3-tube 29 | yarn or npm install 30 | npm start 31 | ``` 32 | 33 | ### Deployment: GitHub Pages 34 | 35 | ```sh 36 | npm run deploy 37 | ``` 38 | -------------------------------------------------------------------------------- /src/helper.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore' 2 | // import Modernizr from 'modernizr' 3 | 4 | // TODO: stub 5 | const Modernizr = {} 6 | 7 | const helper = {} 8 | 9 | helper.halton = function(index, base) { 10 | var result = 0; 11 | var f = 1 / base; 12 | var i = index; 13 | while(i > 0) { 14 | result = result + f * (i % base); 15 | i = Math.floor(i / base); 16 | f = f / base; 17 | } 18 | return result; 19 | }; 20 | helper.hRandom = function(min, max){ 21 | if (helper.hRandomIndex == undefined) helper.hRandomIndex = 0; 22 | var h = helper.halton(helper.hRandomIndex, 3), 23 | rand = h * (max-min) + min; 24 | 25 | helper.hRandomIndex++; 26 | 27 | return rand; 28 | }; 29 | helper.floorToNearest = function(num, nearest){ 30 | return nearest * Math.floor(num/nearest); 31 | }; 32 | helper.localStore = function(key){ 33 | if (!Modernizr.localstorage) return false; 34 | 35 | return JSON.parse(localStorage.getItem(key)); 36 | }; 37 | helper.localStoreRemove = function(key){ 38 | if (!Modernizr.localstorage) return false; 39 | 40 | localStorage.removeItem(key); 41 | }; 42 | helper.localStoreSet = function(key, value){ 43 | if (!Modernizr.localstorage) return false; 44 | 45 | localStorage.setItem(key, JSON.stringify(value)); 46 | }; 47 | helper.parameterize = function(str){ 48 | return str.trim().replace(/[^a-zA-Z0-9-\s]/g, '').replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase(); 49 | }; 50 | helper.parseQueryString = function(queryString){ 51 | var params = {}; 52 | if(queryString){ 53 | _.each( 54 | _.map(decodeURI(queryString).split(/&/g),function(el,i){ 55 | var aux = el.split('='), o = {}; 56 | if(aux.length >= 1){ 57 | var val = undefined; 58 | if(aux.length == 2) 59 | val = aux[1]; 60 | o[aux[0]] = val; 61 | } 62 | return o; 63 | }), 64 | function(o){ 65 | _.extend(params,o); 66 | } 67 | ); 68 | } 69 | return params; 70 | }; 71 | helper.randomString = function(length){ 72 | var text = "", 73 | alpha = "abcdefghijklmnopqrstuvwxyz", 74 | alphanum = "abcdefghijklmnopqrstuvwxyz0123456789", 75 | length = length || 8; 76 | for( var i=0; i < length; i++ ) { 77 | if ( i <= 0 ) { // must start with letter 78 | text += alpha.charAt(Math.floor(Math.random() * alpha.length)); 79 | } else { 80 | text += alphanum.charAt(Math.floor(Math.random() * alphanum.length)); 81 | } 82 | } 83 | return text; 84 | }; 85 | helper.round = function(num, dec) { 86 | num = parseFloat( num ); 87 | dec = dec || 0; 88 | return Math.round(num * Math.pow(10, dec)) / Math.pow(10, dec); 89 | }; 90 | helper.roundToNearest = function(num, nearest){ 91 | return nearest * Math.round(num/nearest); 92 | }; 93 | helper.token = function(){ 94 | return Math.random().toString(36).substr(2); 95 | }; 96 | 97 | export default helper; 98 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "width": 2400, 3 | "height": 3600, 4 | "yUnit": 50, 5 | "xUnit": 80, 6 | "minWidth": 1000, 7 | "bgColor": "#f2e9b8", 8 | "padding": [200, 200], 9 | "textColor": "#000000", 10 | "fontFamily": "OpenSans, sans-serif", 11 | "fontSize": 13, 12 | "fontWeight": "normal", 13 | "maxLines": 32, 14 | "maxPointsPerLine": 32, 15 | "minTextLength": 1, 16 | "maxTextLength": 24, 17 | "pathInterpolation": "basis", // linear, basis, cardinal, monotone 18 | "pointColor": "#ffffff", 19 | "pointColorInverse": "#444444", 20 | "borderColor": "#444444", 21 | "borderColorInverse": "#ffffff", 22 | "borderWidth": 2, 23 | "borderRadius": 4, 24 | "cornerRadius": 40, 25 | "pointRadius": 4, 26 | "pointRadiusLarge": 10, 27 | "strokeWidth": 8, 28 | "strokeOpacity": 0.9, 29 | "offsetWidth": 12, 30 | "minXDiff": 5, 31 | "hubSize": 4, 32 | "animate": true, 33 | "animationDuration": 2000, 34 | "colors": [ 35 | {"hex":"#E5303C","group":"red"}, 36 | {"hex":"#F6802C","group":"orange"}, 37 | {"hex":"#FEC91A","group":"yellow"}, 38 | {"hex":"#45A844","group":"green"}, 39 | {"hex":"#3D84CA","group":"blue"}, 40 | {"hex":"#C1477F","group":"violet"}, 41 | {"hex":"#A59D90","group":"gray"}, 42 | {"hex":"#F4897F","group":"red"}, 43 | {"hex":"#FFA769","group":"orange"}, 44 | {"hex":"#FFD45C","group":"yellow"}, 45 | {"hex":"#74C063","group":"green"}, 46 | {"hex":"#86D0ED","group":"blue"}, 47 | {"hex":"#BF6992","group":"violet"}, 48 | {"hex":"#BAB4AA","group":"gray"}, 49 | {"hex":"#D8448B","group":"red"}, 50 | {"hex":"#E57257","group":"orange"}, 51 | {"hex":"#FCAB1D","group":"yellow"}, 52 | {"hex":"#45A384","group":"green"}, 53 | {"hex":"#6285D1","group":"blue"}, 54 | {"hex":"#9975BC","group":"violet"}, 55 | {"hex":"#999082","group":"gray"}, 56 | {"hex":"#EA83B9","group":"red"}, 57 | {"hex":"#E28876","group":"orange"}, 58 | {"hex":"#FFD773","group":"yellow"}, 59 | {"hex":"#75BCA2","group":"green"}, 60 | {"hex":"#85A5DD","group":"blue"}, 61 | {"hex":"#AB90C4","group":"violet"}, 62 | {"hex":"#CCC6BE","group":"gray"}, 63 | {"hex":"#E05865","group":"red"}, 64 | {"hex":"#EA965C","group":"orange"}, 65 | {"hex":"#F4D069","group":"yellow"}, 66 | {"hex":"#73AA72","group":"green"}, 67 | {"hex":"#5D98C9","group":"blue"}, 68 | {"hex":"#D162A4","group":"violet"}, 69 | {"hex":"#A09D9A","group":"gray"} 70 | ], 71 | "legend": { 72 | "padding": 40, 73 | "bgColor": "#e8ddc2", 74 | "columns": 2, 75 | "columnThreshold": 12, 76 | "columnWidth": 320, 77 | "titleFontSize": 24, 78 | "titleMaxLineChars": 30, 79 | "titleLineHeight": 30, 80 | "fontSize": 14, 81 | "lineHeight": 30, 82 | "gridUnit": 20 83 | }, 84 | "pathTypes": [ 85 | {"xDirection":"s","directions":["s"]}, // straight line 86 | {"xDirection":"e","directions":["s","e","s"]}, // elbow right 87 | {"xDirection":"w","directions":["s","w","s"]}, // elbow left 88 | ] 89 | } 90 | 91 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "title": "Brian's Memory Map 2004-2014", 3 | "stations": [ 4 | {"label":"High School Graduation","lines":["Mom","Dad","Brandon","Patricia", "Angela"]}, 5 | {"label":"Broadway Dance Company","lines":["Hoang"]}, 6 | {"label":"John Jay Ocho","lines":["Michelle S"]}, 7 | {"label":"Freshmen Orientation","lines":["Patricia","Michelle S"]}, 8 | {"label":"You Are My Joy Born","lines":["Stephanie"]}, 9 | {"label":"Lerner Dance Practice","lines":["Calvin","Pris"]}, 10 | {"label":"Low Library Steps","lines":["Pris"]}, 11 | {"label":"Joybombing","lines":["Stephanie"]}, 12 | {"label":"First CUNUFF","lines":["Hoang","Calvin","Pris"]}, 13 | {"label":"Painting in John Jay","lines":["Hoang"]}, 14 | {"label":"Mudd Roof","lines":["Pris"]}, 15 | {"label":"UES Summer Roomies","lines":["Hoang","Calvin"]}, 16 | {"label":"AAIFF Chinatown Film","lines":["Hoang","Calvin", "Kai"]}, 17 | {"label":"Leopard Lounge","lines":["Calvin","Kai", "Amelia"]}, 18 | {"label":"Staten Island Ferry","lines":["Hoang","Calvin","Kai","Amelia"]}, 19 | {"label":"Hudson River","lines":["Kai"]}, 20 | {"label":"Night of Chaos","lines":["Kai","Calvin","Amelia"]}, 21 | {"label":"Six Flags","lines":["Calvin","Todd","Kai","Amelia","Ness"]}, 22 | {"label":"Hera Gives Birth","lines":["Hoang","Calvin","Kai"]}, 23 | {"label":"Belle and Sebastian","lines":["Kai"]}, 24 | {"label":"Skydiving","lines":["Kai"]}, 25 | {"label":"Guatemala","lines":["Kai"]}, 26 | {"label":"France","lines":["Kai","Amelia","Ness"], "date":"5/27/07"}, 27 | {"label":"Winter Camping","lines":["Kai"]}, 28 | {"label":"East Coast Road Trip","lines":["Kai"]}, 29 | {"label":"Dominican Republic","lines":["Kai"]}, 30 | {"label":"Montreal","lines":["Kai"]}, 31 | {"label":"First FC Dinner","lines":["Liza","Katie","Vicky"], "date": "4/25/08"}, 32 | {"label":"How To Ride A Bike","lines":["Hoang"]}, 33 | {"label":"Moving Into Metro","lines":["Calvin","Rahul","Teriha"], "date": "6/1/08"}, 34 | {"label":"Natsumi Birthday","lines":["Calvin","Rahul","Teriha","Vicky"]}, 35 | {"label":"Skydiving","lines":["Liza","Katie","Vicky"], "date": "6/9/08"}, 36 | {"label":"A First Goodbye","lines":["Calvin","Teriha","Liza","Ness"]}, 37 | {"label":"Ice Skating","lines":["Pris"], "date": "11/17/08"}, 38 | {"label":"Survival Camping","lines":["Liza","Katie","Vicky"], "date": "11/17/08"}, 39 | {"label":"Pumpkin Picking","lines":["Brandon","Calvin","Rahul","Teriha","Liza","Katie","Vicky"], "date": "11/20/08"}, 40 | {"label":"Trapezing","lines":["Rahul","Liza","Katie","Vicky"], "date": "12/15/08"}, 41 | {"label":"Adirondacks","lines":["Calvin","Christian","Liza","Vicky"], "date": "3/13/09"}, 42 | {"label":"Monterey / Salinas","lines":["Pris"], "date": "5/19/09"}, 43 | {"label":"Time's Up Film Shootout","lines":["Calvin","Rahul","Todd","Shakthi"], "date": "7/13/09"}, 44 | {"label":"Summerscape","lines":["Calvin","Rahul","Todd","Christian","Liza","Katie","Vicky","Shakthi"], "date": "7/13/09"}, 45 | {"label":"Silent Camping","lines":["Liza","Katie","Vicky"], "date": "8/8/09"}, 46 | {"label":"Fire Island","lines":["Teriha"], "date": "9/4/09"}, 47 | {"label":"Storm King & Palaia","lines":["Teriha"], "date": "9/19/09"}, 48 | {"label":"Warwick Valley","lines":["Teriha"], "date": "10/10/09"}, 49 | {"label":"Puerto Vallarta","lines":["Liza","Vicky"], "date": "10/29/09"}, 50 | {"label":"Mexican Honeymoon","lines":["Liza"], "date": "10/29/09"}, 51 | {"label":"The Shire","lines":["Calvin","Rahul","Todd","Liza","Hiro","Priya"], "date": "1/11/10"}, 52 | {"label":"Birthday Kidnapping","lines":["Calvin","Rahul","Todd","Christian","Teriha"], "date": "1/29/10"}, 53 | {"label":"Cage Fighting","lines":["Brandon","Angela","Calvin","Rahul","Todd","Christian","Liza","Katie","Vicky","Hiro","Priya"], "date": "1/29/10"}, 54 | {"label":"Virginia Cabin","lines":["Liza","Vicky","Hiro"], "date": "3/19/10"}, 55 | {"label":"DreamIt Philadelphia","lines":["Todd"],"date": "5/29/2010"}, 56 | {"label":"Maine","lines":["Liza","Vicky","Hiro"],"date": "5/27/2011"}, 57 | {"label":"Juice Party","lines":["Liza","Katie","Vicky","Todd","Becky"],"date": "6/4/2011"}, 58 | {"label":"New Hampshire Camping","lines":["Dad","Brandon"],"date": "6/22/2011"}, 59 | {"label":"Miami Wedding","lines":["Patricia","Michelle S"],"date": "7/30/2011"}, 60 | {"label":"Oriental Garden Birthday","lines":["Calvin","Christian","Cynthia"],"date": "12/5/2011"}, 61 | {"label":"Tree Decorating","lines":["Liza","Katie","Hiro","Vicky","Becky","Amanda","Todd"],"date": "12/11/2011"}, 62 | {"label":"Breakneck / Storm King","lines":["Becky"],"date": "12/29/2011"}, 63 | {"label":"Paint Party / Little Stripper","lines":["Brandon","Angela","Hoang","Stephanie","Calvin","Rahul","Liza","Katie","Hiro","Becky","Priya","Amanda"],"date": "1/29/2012"}, 64 | {"label":"Puerto Rico","lines":["Liza","Katie","Vicky"],"date": "2/26/2012"}, 65 | {"label":"Sketch Night","lines":["Becky"]}, 66 | {"label":"Central Park Collabo-Art","lines":["Cynthia"],"date": "5/12/2012"}, 67 | {"label":"World Science Festival","lines":["Hoang"],"date": "6/2/2012"}, 68 | {"label":"Color Run","lines":["Angela"],"date": "6/18/2012"}, 69 | {"label":"Bermuda Cruise","lines":["Mom","Dad","Brandon"],"date": "6/30/2012"}, 70 | {"label":"First Joyathon","lines":["Angela","Stephanie","Calvin","Christian","Cynthia","Virginia"],"date": "9/13/2012"}, 71 | {"label":"Hurricane Sandy","lines":["Liza","Michelle H","Thomas"],"date": "10/29/2012"}, 72 | {"label":"Gay Halloween","lines":["Liza","Michelle H","Thomas"]}, 73 | {"label":"Homeland","lines":["Liza","Michelle H","Thomas"]}, 74 | {"label":"New Orleans","lines":["Liza","Becky","Amanda"],"date": "1/9/2013"}, 75 | {"label":"Chinatown Pho & Boba","lines":["Michelle H"]}, 76 | {"label":"Sketchy Film at MoMA","lines":["Michelle H"]}, 77 | {"label":"Curling","lines":["Liza","Becky"],"date":"4/21/2013"}, 78 | {"label":"Kidney Stones","lines":["Liza","Michelle H","Thomas"]}, 79 | {"label":"Children's Museum","lines":["Hoang"]}, 80 | {"label":"Odie","lines":["Liza","Michelle H","Thomas"],"date": "7/4/2013"}, 81 | {"label":"July 4th Wedding","lines":["Michelle S","Stephanie"],"date": "7/4/2013"}, 82 | {"label":"Late Night Ippudo","lines":["Michelle H","Thomas"]}, 83 | {"label":"First Turing Meeting","lines":["Virginia"]}, 84 | {"label":"Harlem Scavenger Hunt","lines":["Patricia"]}, 85 | {"label":"The National","lines":["Virginia"]}, 86 | {"label":"Queens Art Walk","lines":["Teriha"]} 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /src/tube.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import _ from 'underscore' 3 | import $ from 'jquery' 4 | 5 | import panZoom from 'jquery.panzoom' 6 | $.panZoom = panZoom; 7 | 8 | import helper from './helper' 9 | 10 | const tube = function() { 11 | this.initialize() 12 | } 13 | 14 | tube.prototype = { 15 | initialize: function() { 16 | // add listeners 17 | this.addListeners(); 18 | }, 19 | 20 | addDotStyles: function(dots, options){ 21 | var pointColor = options.pointColor, 22 | borderColor = options.borderColor, 23 | borderWidth = options.borderWidth; 24 | 25 | _.each(dots, function(dot){ 26 | dot.className = dot.className || ''; 27 | // train symbol 28 | if (dot.symbol){ 29 | dot.borderColor = dot.pointColor; 30 | dot.borderWidth = borderWidth; 31 | // point/station 32 | } else { 33 | dot.pointColor = pointColor; 34 | dot.borderColor = borderColor; 35 | dot.borderWidth = borderWidth; 36 | } 37 | }); 38 | 39 | return dots; 40 | }, 41 | 42 | addRectStyles: function(rects, options){ 43 | var pointColor = options.pointColor, 44 | borderColor = options.borderColor, 45 | borderWidth = options.borderWidth, 46 | borderRadius = options.borderRadius, 47 | pointRadius = options.pointRadius, 48 | dotSize = pointRadius*2, 49 | offsetWidth = options.offsetWidth - dotSize; 50 | 51 | _.each(rects, function(rect){ 52 | rect.className = rect.className || ''; 53 | // hub 54 | if (rect.hubSize) { 55 | rect.pointColor = pointColor; 56 | rect.borderColor = borderColor; 57 | rect.borderWidth = borderWidth; 58 | rect.borderRadius = borderRadius; 59 | rect.width = rect.hubSize*dotSize + offsetWidth*(rect.hubSize-1); 60 | rect.height = dotSize; 61 | rect.rectX = rect.x - pointRadius; 62 | rect.rectY = rect.y - pointRadius; 63 | // legend 64 | } else if (rect.type=="legend") { 65 | rect.borderColor = borderColor; 66 | rect.borderWidth = borderWidth; 67 | rect.borderRadius = 0; 68 | } 69 | }); 70 | 71 | return rects; 72 | }, 73 | 74 | addLabelStyles: function(labels, options){ 75 | var fontFamily = options.fontFamily, 76 | textColor = options.textColor, 77 | fontSize = options.fontSize, 78 | fontWeight = options.fontWeight; 79 | 80 | _.each(labels, function(label){ 81 | label.className = label.className || ''; 82 | label.fontFamily = fontFamily; 83 | label.alignment = "middle"; 84 | // symbol 85 | if (label.symbol) { 86 | label.textColor = "#ffffff"; 87 | label.fontSize = 14; 88 | label.fontWeight = "normal"; 89 | label.anchor = "middle"; 90 | label.text = label.symbol; 91 | label.labelX = label.labelX!==undefined ? label.labelX : label.x; 92 | label.labelY = label.labelY!==undefined ? label.labelY : label.y + 1; 93 | // label 94 | } else { 95 | label.textColor = textColor; 96 | label.fontSize = label.fontSize || fontSize; 97 | label.fontWeight = fontWeight; 98 | label.anchor = label.anchor || "end"; 99 | label.text = label.text || label.label; 100 | label.labelX = label.labelX!==undefined ? label.labelX : label.x-10; 101 | label.labelY = label.labelY!==undefined ? label.labelY : label.y; 102 | } 103 | }); 104 | 105 | return labels; 106 | }, 107 | 108 | addLineStyles: function(lines, options){ 109 | var strokeOpacity = options.strokeOpacity, 110 | strokeWidth = options.strokeWidth; 111 | 112 | _.each(lines, function(line){ 113 | line.className = line.className || ''; 114 | line.strokeOpacity = strokeOpacity; 115 | // symbol 116 | if (line.type=="symbol") { 117 | line.color = "#aaaaaa"; 118 | line.strokeWidth = 2; 119 | line.strokeDash = "2,2"; 120 | 121 | // normal line 122 | } else { 123 | line.strokeWidth = strokeWidth; 124 | line.strokeDash = "none"; 125 | } 126 | }); 127 | 128 | return lines; 129 | }, 130 | 131 | addListeners: function(){ 132 | var that = this; 133 | $(document).on('keydown', function(e){ 134 | switch(e.keyCode) { 135 | // o - output to svg 136 | case 79: 137 | if (e.ctrlKey) that.exportSVG(); 138 | break; 139 | 140 | default: 141 | break; 142 | } 143 | }); 144 | }, 145 | 146 | adjustHeight: function(height, stationCount, options) { 147 | var yUnit = options.yUnit, 148 | paddingY = options.padding[1], 149 | activeH = height - paddingY*2; 150 | 151 | // make height shorter if not enough stations 152 | if (Math.floor(height/stationCount) > yUnit) { 153 | activeH = yUnit*stationCount; 154 | height = activeH + paddingY*2; 155 | } 156 | 157 | return height; 158 | }, 159 | 160 | adjustWidth: function(width, stationCount, options){ 161 | var xUnit = options.xUnit, 162 | paddingX = options.padding[0], 163 | activeW = width - paddingX*2; 164 | 165 | // make height shorter if not enough stations 166 | if (Math.floor(width/stationCount) > xUnit) { 167 | activeW = xUnit*stationCount; 168 | width = activeW + paddingX*2; 169 | } 170 | 171 | width = _.max([options.minWidth, width]); 172 | 173 | return width; 174 | }, 175 | 176 | drawDots: function(svg, dots) { 177 | svg.selectAll("dot") 178 | .data(dots) 179 | .enter().append("circle") 180 | .attr("r", function(d) { return d.pointRadius; }) 181 | .attr("cx", function(d) { return d.x; }) 182 | .attr("cy", function(d) { return d.y; }) 183 | .attr("class", function(d) { return d.className || ''; }) 184 | .style("fill", function(d){ return d.pointColor; }) 185 | .style("stroke", function(d){ return d.borderColor; }) 186 | .style("stroke-width", function(d){ return d.borderWidth; }); 187 | }, 188 | 189 | drawRects: function(svg, rects){ 190 | _.each(rects, function(r){ 191 | svg.append("rect") 192 | .attr("width", r.width) 193 | .attr("height", r.height) 194 | .attr("x", r.rectX) 195 | .attr("y", r.rectY) 196 | .attr("rx", r.borderRadius) 197 | .attr("ry", r.borderRadius) 198 | .attr("class", r.className) 199 | .style("fill", r.pointColor) 200 | .style("stroke", r.borderColor) 201 | .style("stroke-width", r.borderWidth); 202 | }); 203 | }, 204 | 205 | drawLabels: function(svg, labels, options) { 206 | svg.selectAll("text") 207 | .data(labels) 208 | .enter().append("text") 209 | .text( function (d) { return d.text; }) 210 | .attr("class", function(d) { return d.className || ''; }) 211 | .attr("x", function(d) { return d.labelX; }) 212 | .attr("y", function(d) { return d.labelY; }) 213 | .attr("text-anchor",function(d){ return d.anchor; }) 214 | .attr("alignment-baseline",function(d){ return d.alignment; }) 215 | .attr("dominant-baseline",function(d){ return d.alignment; }) 216 | .attr("font-size", function(d){ return d.fontSize; }) 217 | .style("font-family", function(d){ return d.fontFamily; }) 218 | .style("font-weight", function(d){ return d.fontWeight; }) 219 | .style("fill", function(d){ return d.textColor; }); 220 | }, 221 | 222 | drawLines: function(svg, lines, options) { 223 | var that = this, 224 | pathInterpolation = options.pathInterpolation, 225 | animate = options.animate, 226 | animationDuration = options.animationDuration, 227 | svg_line; 228 | 229 | svg_line = d3.svg.line() 230 | .interpolate(pathInterpolation) 231 | .x(function(d) { return d.x; }) 232 | .y(function(d) { return d.y; }); 233 | 234 | _.each(lines, function(line){ 235 | var points = line.points, 236 | path = svg.append("path") 237 | .attr("d", svg_line(points)) 238 | .attr("class", line.className) 239 | .style("stroke", line.color) 240 | .style("stroke-width", line.strokeWidth) 241 | .style("stroke-opacity", line.strokeOpacity) 242 | .style("fill", "none"); 243 | 244 | // animate if it's a solid line 245 | if (path && animate && line.strokeDash=="none" && line.className.indexOf("primary")>=0) { 246 | var totalLength = path.node().getTotalLength(); 247 | path 248 | .attr("stroke-dasharray", totalLength + " " + totalLength) 249 | .attr("stroke-dashoffset", totalLength) 250 | .transition() 251 | .duration(animationDuration) 252 | .ease("linear") 253 | .attr("stroke-dashoffset", 0) 254 | 255 | // otherwise, set the stroke dash 256 | } else { 257 | path.style("stroke-dasharray", line.strokeDash); 258 | } 259 | 260 | }); 261 | }, 262 | 263 | drawMap: function(lines, legend, width, height, options){ 264 | var bgColor = options.bgColor, 265 | svg, points = [], dots = [], labels = [], rects = [], 266 | showLegend = parseInt(options.transit.legend), 267 | showLabels = parseInt(options.transit.labels); 268 | 269 | // reset if already there 270 | if ($("#map-svg").length > 0) { 271 | $("#map-svg").remove(); 272 | } 273 | 274 | // init svg and add to DOM 275 | svg = d3.select("#svg-wrapper") 276 | .append("svg") 277 | .attr("id", "map-svg") 278 | .attr("width", width) 279 | .attr("height", height); 280 | 281 | // extract points, dots, labels from lines 282 | points = _.flatten( _.pluck(lines, "points") ); 283 | dots = _.filter(points, function(p){ return p.pointRadius && p.pointRadius > 0; }); 284 | if (showLabels) labels = _.filter(points, function(p){ return p.label !== undefined || p.symbol !== undefined; }); 285 | rects = _.filter(points, function(p){ return p.hubSize; }); 286 | 287 | // add legend items 288 | if (showLegend) { 289 | lines = _.union(lines, legend.lines); 290 | dots = _.union(dots, legend.dots); 291 | labels = _.union(labels, legend.labels); 292 | } 293 | 294 | // add styles 295 | lines = this.addLineStyles(lines, options); 296 | dots = this.addDotStyles(dots, options); 297 | labels = this.addLabelStyles(labels, options); 298 | rects = this.addRectStyles(rects, options); 299 | legend.rects = this.addRectStyles(legend.rects, options); 300 | 301 | // draw lines, dots, labels, rects 302 | if (showLegend) this.drawRects(svg, legend.rects); 303 | this.drawLines(svg, lines, options); 304 | this.drawDots(svg, dots, options); 305 | this.drawRects(svg, rects, options); 306 | this.drawLabels(svg, labels, options); 307 | }, 308 | 309 | getColor: function(lines, colors){ 310 | var i = lines.length; 311 | if (i>=colors.length) { 312 | i = i % lines.length; 313 | } 314 | return colors[i]; 315 | }, 316 | 317 | exportSVG: function(){ 318 | var dataUrl = this.getImageDataUrl(); 319 | window.open(dataUrl, '_blank'); 320 | }, 321 | 322 | getImageDataUrl: function(){ 323 | var svg_xml = $("#map-svg").parent().html(), 324 | b64 = window.btoa(svg_xml); 325 | 326 | return "data:image/svg+xml;base64,\n"+b64; 327 | }, 328 | 329 | getLengths: function(xDiff, yDiff, directions, y, options) { 330 | var lengths = [], 331 | rand = helper.hRandom(20, 80) / 100, 332 | yUnit = options.yUnit, 333 | paddingY = options.padding[1], 334 | i = 0, timeout = 10, 335 | firstY; 336 | 337 | // don't let in-between points overlap with yUnit 338 | while((y+Math.round(yDiff*rand)-paddingY)%yUnit===0 && i0) { 416 | xDirection = "e"; 417 | } else if (xDiff<0) { 418 | xDirection = "w"; 419 | } 420 | 421 | // filter and choose random path type 422 | pathTypes = _.filter(pathTypes, function(pt){ 423 | return pt.xDirection===xDirection; 424 | }); 425 | pathType = _.sample(pathTypes); 426 | 427 | // get points if path type exists 428 | if (pathType && xDirection) { 429 | 430 | // retrieve directions 431 | var directions = pathType.directions; 432 | 433 | // retrieve lengths 434 | var x = x1, y = y1, 435 | lengths = that.getLengths(xDiff, yDiff, directions, y, options); 436 | 437 | // generate points 438 | _.each(directions, function(direction, i){ 439 | var length = lengths[i], 440 | point = that.translateCoordinates(x, y, direction, length), 441 | pointR1 = false, pointR2 = false; 442 | 443 | x = point.x; 444 | y = point.y; 445 | point.id = _.uniqueId('p'); 446 | point.direction1 = direction; 447 | 448 | // add transition points if corner radius 449 | if (cornerRadius>0 && cornerRadius0) { 469 | points[i-1].direction2 = direction; 470 | } 471 | }); 472 | 473 | // ensure the last point matches target 474 | if (points.length > 0) { 475 | points[points.length-1].x = x2; 476 | points[points.length-1].y = y2; 477 | } 478 | 479 | // otherwise, just return target point 480 | } else { 481 | points.push({ 482 | id: _.uniqueId('p'), 483 | direction1: 's', 484 | x: x2, 485 | y: y2 486 | }); 487 | } 488 | 489 | return points; 490 | }, 491 | 492 | getSymbol: function(lineLabel, lines) { 493 | // prioritize characters: uppercase label, numbers, lowercase label 494 | var str = lineLabel.toUpperCase() + "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" + lineLabel.toLowerCase() + "abcdefghijklmnopqrstuvwxyz", 495 | symbols = _.pluck(lines, "symbol"), 496 | symbol = str.charAt(0); 497 | 498 | // strip spaces 499 | str = str.replace(" ",""); 500 | 501 | // loop through string's characters 502 | for(var i=0; i titleMaxLineChars) { 524 | lines.push(currentLine); 525 | currentLine = word; 526 | } else { 527 | currentLine += ' ' + word; 528 | } 529 | }); 530 | 531 | if (currentLine.length) lines.push(currentLine); 532 | 533 | return lines; 534 | }, 535 | 536 | makeEndLines: function(lines, options){ 537 | var pointRadiusLarge = options.pointRadiusLarge, 538 | lineLength = pointRadiusLarge * 2 + 10, 539 | endLines = [], 540 | yHash = {}; 541 | 542 | _.each(lines, function(line, i){ 543 | var firstPoint = line.points[0], 544 | lastPoint = line.points[line.points.length-1], 545 | lineClassName = helper.parameterize('line-'+line.label) + ' end-line', 546 | pointClassName = helper.parameterize('point-'+line.label) + ' end-line', 547 | lineStart = { className: lineClassName + ' start-line', type: 'symbol', points: [] }, 548 | lineEnd = { className: lineClassName, type: 'symbol', points: [] }, 549 | 550 | fpId = 'p'+firstPoint.y, 551 | lpId = 'p'+lastPoint.y; 552 | 553 | // keep track of existing y points 554 | if (yHash[fpId]!==undefined) { 555 | yHash[fpId]++; 556 | } else { 557 | yHash[fpId] = 0; 558 | } 559 | if (yHash[lpId]!==undefined) { 560 | yHash[lpId]++; 561 | } else { 562 | yHash[lpId] = 0; 563 | } 564 | 565 | // add start line 566 | lineStart.points.push({ 567 | x: firstPoint.x, 568 | y: firstPoint.y - lineLength - yHash[fpId]%2*lineLength, // stagger y's that are next to each other 569 | symbol: line.symbol, 570 | pointColor: line.color, 571 | pointRadius: pointRadiusLarge, 572 | className: pointClassName + ' symbol' 573 | }); 574 | lineStart.points.push({ 575 | x: firstPoint.x, 576 | y: firstPoint.y, 577 | className: pointClassName 578 | }); 579 | 580 | // make end line 581 | lineEnd.points.push({ 582 | x: lastPoint.x, 583 | y: lastPoint.y, 584 | className: pointClassName 585 | }); 586 | lineEnd.points.push({ 587 | x: lastPoint.x, 588 | y: lastPoint.y + lineLength + yHash[lpId]%2*lineLength, // stagger y's that are next to each other 589 | symbol: line.symbol, 590 | pointColor: line.color, 591 | pointRadius: pointRadiusLarge, 592 | className: pointClassName + ' symbol' 593 | }); 594 | 595 | // add end lines 596 | endLines.push(lineStart, lineEnd); 597 | 598 | }); 599 | 600 | return endLines; 601 | }, 602 | 603 | makeLegend: function(width, lines, options){ 604 | var // options 605 | canvasWidth = width, 606 | canvasPaddingX = options.padding[0], 607 | canvasPaddingY = options.padding[1], 608 | title = options.title, 609 | pointRadius = options.pointRadius, 610 | pointRadiusLarge = options.pointRadiusLarge, 611 | borderWidth = options.borderWidth, 612 | columns = lines.length > options.legend.columnThreshold ? options.legend.columns : 1, 613 | legendWidth = options.legend.columnWidth * columns, 614 | padding = options.legend.padding, 615 | bgColor = options.legend.bgColor, 616 | titleFontSize = options.legend.titleFontSize, 617 | titleMaxLineChars = options.legend.titleMaxLineChars, 618 | titleLineHeight = options.legend.titleLineHeight, 619 | fontSize = options.legend.fontSize, 620 | lineHeight = options.legend.lineHeight, 621 | gridUnit = options.legend.gridUnit, 622 | // calculations 623 | columnWidth = Math.floor((legendWidth-padding*2)/columns), 624 | titleLines = this.getTitleLines(title, titleMaxLineChars), 625 | x1 = legendWidth >= canvasWidth/2 ? canvasWidth - legendWidth - padding - borderWidth*2 : canvasWidth/2, 626 | y1 = canvasPaddingY, 627 | lineCount = lines.length, 628 | height = padding *2 + lineHeight*Math.ceil(lineCount/columns) + titleLineHeight*titleLines.length, 629 | // initializers 630 | legend = {dots: [], labels: [], lines: [], rects: []}; 631 | 632 | // break up lines into columns 633 | var columnLines = [], 634 | perColumn = Math.floor(lineCount/columns), 635 | remainder = lineCount%columns, 636 | lineIndex = 0; 637 | _.times(columns, function(i){ 638 | var start = lineIndex, 639 | end = lineIndex+perColumn; 640 | // add remainder to first column 641 | if (i===0) end += remainder; 642 | columnLines.push( 643 | lines.slice(start, end) 644 | ); 645 | lineIndex = end; 646 | }); 647 | 648 | // create rectangle 649 | legend.rects.push({ 650 | width: legendWidth, 651 | height: height, 652 | rectX: x1, 653 | rectY: y1, 654 | pointColor: bgColor, 655 | type: "legend" 656 | }); 657 | 658 | // add legend padding 659 | x1 += padding; 660 | y1 += padding; 661 | 662 | // add title 663 | _.each(titleLines, function(titleLine, i){ 664 | legend.labels.push({ 665 | text: titleLine, 666 | anchor: "start", 667 | labelX: x1, 668 | labelY: y1, 669 | fontSize: titleFontSize, 670 | type: "legendTitle" 671 | }); 672 | y1 += titleLineHeight; 673 | }); 674 | 675 | // add a space 676 | y1 += gridUnit; 677 | 678 | // loop through columns 679 | _.each(columnLines, function(columnLine, c){ 680 | 681 | var colOffset = columnWidth * c, 682 | y2 = y1; 683 | 684 | // loop through lines 685 | _.each(columnLine, function(line, i){ 686 | 687 | var lineClassName = helper.parameterize('line-'+line.label) + ' legend', 688 | pointClassName = helper.parameterize('point-'+line.label) + ' legend'; 689 | 690 | // add symbol dot 691 | legend.dots.push({ 692 | x: colOffset+x1+pointRadiusLarge, y: y2, 693 | pointColor: line.color, 694 | symbol: line.symbol, 695 | pointRadius: pointRadiusLarge, 696 | className: pointClassName 697 | }); 698 | // add symbol label 699 | legend.labels.push({ 700 | text: line.symbol, 701 | labelX: colOffset+x1+pointRadiusLarge, 702 | labelY: y2+1, 703 | symbol: line.symbol, 704 | className: pointClassName 705 | }); 706 | 707 | // add line 708 | legend.lines.push({ 709 | color: line.color, 710 | type: "legend", 711 | className: lineClassName, 712 | points: [ 713 | {x: colOffset+x1+pointRadiusLarge*2, y: y2, className: pointClassName}, 714 | {x: colOffset+x1+pointRadiusLarge*2+gridUnit*4, y: y2, className: pointClassName} 715 | ] 716 | }); 717 | // add line dot 718 | legend.dots.push({ 719 | x: colOffset+x1+pointRadiusLarge*2+gridUnit*2, y: y2, 720 | pointRadius: pointRadius, 721 | className: pointClassName 722 | }); 723 | // add line label 724 | legend.labels.push({ 725 | text: line.label + " Line", 726 | labelX: colOffset+x1+pointRadiusLarge*2+gridUnit*5, 727 | labelY: y2, 728 | fontSize: fontSize, 729 | anchor: "start", 730 | type: "legend", 731 | className: pointClassName 732 | }); 733 | 734 | y2+=lineHeight; 735 | }); 736 | 737 | 738 | }); 739 | 740 | return legend; 741 | 742 | }, 743 | 744 | makeLines: function(stations, width, height, options){ 745 | var that = this, 746 | // options 747 | paddingX = options.padding[0], 748 | paddingY = options.padding[1], 749 | colors = options.colors, 750 | pathTypes = options.pathTypes, 751 | offsetWidth = options.offsetWidth, 752 | cornerRadius = options.cornerRadius, 753 | minXDiff = options.minXDiff, 754 | pointRadius = options.pointRadius, 755 | hubSize = options.hubSize, 756 | // calculations 757 | activeW = width - paddingX*2, 758 | activeH = height - paddingY*2, 759 | boundaries = {minX: paddingX, minY: paddingY, maxX: width-paddingX, maxY: height-paddingY}, 760 | stationCount = stations.length, 761 | yUnit = Math.floor(activeH/stationCount), 762 | // initializers 763 | lines = [], 764 | prevLines = []; 765 | 766 | // ensure y-unit is 2 or more 767 | if (yUnit<2) yUnit = 2; 768 | options.yUnit = yUnit; 769 | 770 | // loop through stations 771 | _.each(stations, function(station, i){ 772 | var nextY = paddingY + i * yUnit, // next available yUnit 773 | nextX = that.getNextX(boundaries, i, stationCount, activeW, minXDiff), // random x 774 | lineCount = station.lines.length, 775 | firstX = nextX; 776 | 777 | // loop through station's lines 778 | _.each(station.lines, function(lineLabel, j){ 779 | // if line already exists 780 | var foundLine = _.findWhere(lines, {label: lineLabel}), 781 | prevPoint = false, 782 | lineClassName = helper.parameterize('line-'+lineLabel) + " primary", 783 | pointClassName = helper.parameterize('point-'+lineLabel), 784 | newPoint; 785 | 786 | // retieve previous point 787 | if (foundLine) { 788 | prevPoint = _.last(foundLine.points); 789 | } 790 | 791 | // if line is in previous lines, it will be straight 792 | if (prevLines.indexOf(lineLabel)>=0 && prevPoint) { 793 | nextX = prevPoint.x; 794 | 795 | // if line already exists, make sure X is within 20% of previous X 796 | } else if (prevPoint) { 797 | nextX = that.getNextX(boundaries, i, stationCount, activeW, minXDiff, prevPoint); 798 | } 799 | 800 | // init new point 801 | newPoint = { 802 | id: _.uniqueId('p'), 803 | x: nextX, 804 | y: nextY, 805 | lineLabel: lineLabel, 806 | pointRadius: pointRadius, 807 | className: pointClassName + " station" 808 | }; 809 | 810 | // for first line, just add target point 811 | if (j===0) { 812 | firstX = newPoint.x; 813 | newPoint.label = station.label; // only the target point of the first line gets label 814 | newPoint.className += " primary"; 815 | if (lineCount >= hubSize) { 816 | newPoint.hubSize = lineCount; 817 | newPoint.className += " hub"; 818 | } 819 | 820 | // for additional new lines, place first point next to the first line's target point plus offset 821 | } else { 822 | newPoint.x = firstX + j*offsetWidth; 823 | newPoint.className += " secondary"; 824 | } 825 | 826 | // line already exists 827 | if (foundLine){ 828 | var transitionPoints = [], 829 | lastPoint; 830 | 831 | // retrieve transition points 832 | transitionPoints = that.getPointsBetween(prevPoint, newPoint, pathTypes, cornerRadius, options); 833 | 834 | // add direction2 to previous point 835 | if (transitionPoints.length > 0 && foundLine.points.length > 0) { 836 | lastPoint = _.last(foundLine.points); 837 | lastPoint.direction2 = transitionPoints[0].direction1; 838 | } 839 | 840 | // add transition points 841 | _.each(transitionPoints, function(tp){ 842 | tp.className = pointClassName; 843 | foundLine.points.push(tp); 844 | }); 845 | 846 | // update last point with meta data 847 | lastPoint = _.last(foundLine.points); 848 | lastPoint = _.extend(lastPoint, newPoint); 849 | 850 | // line does not exist, add a new one 851 | } else { 852 | var color = that.getColor(lines, colors), 853 | newLine = { 854 | label: lineLabel, 855 | color: color.hex, 856 | symbol: that.getSymbol(lineLabel, lines), 857 | className: lineClassName, 858 | points: [] 859 | }; 860 | // add point to line, add line to lines 861 | newLine.points.push(newPoint); 862 | lines.push(newLine); 863 | } 864 | 865 | }); 866 | 867 | prevLines = station.lines; 868 | }); 869 | 870 | return lines; 871 | }, 872 | 873 | panZoom: function($selector){ 874 | if ($selector.panzoom("instance")) { 875 | $selector.panzoom("reset"); 876 | $selector.panzoom("destroy"); 877 | } 878 | var $panzoom = $selector.panzoom({ 879 | $zoomIn: $('.svg-zoom-in'), 880 | $zoomOut: $('.svg-zoom-out') 881 | }); 882 | $panzoom.parent().on('mousewheel.focal', function( e ) { 883 | e.preventDefault(); 884 | var delta = e.delta || e.originalEvent.wheelDelta; 885 | var zoomOut = delta ? delta < 0 : e.originalEvent.deltaY > 0; 886 | $panzoom.panzoom('zoom', zoomOut, { 887 | increment: 0.1, 888 | animate: false, 889 | focal: e 890 | }); 891 | }); 892 | }, 893 | 894 | processStations: function(stations){ 895 | var that = this, 896 | lineLabels = _.uniq( _.flatten( _.pluck(stations, 'lines') ) ); // get unique lines 897 | 898 | // loop through each point 899 | _.each(stations, function(station, i){ 900 | // sort all the lines consistently 901 | station.lines = _.sortBy(station.lines, function(lineLabel){ return lineLabels.indexOf(lineLabel); }); 902 | }); 903 | 904 | return stations; 905 | }, 906 | 907 | render: function(options){ 908 | // reset halton sequence index 909 | helper.hRandomIndex = 0; 910 | 911 | // render the map 912 | this.renderMap(options); 913 | 914 | // activate pan-zoom 915 | this.panZoom($("#map-svg")); 916 | }, 917 | 918 | renderMap: function(options){ 919 | var stations = options.transit.stations, 920 | width = options.width, 921 | height = options.height, 922 | pathInterpolation = options.pathInterpolation, 923 | lines = [], endLines = [], legend; 924 | 925 | options.title = options.transit.title; 926 | stations = this.processStations(stations); 927 | height = this.adjustHeight(height, stations.length, options); 928 | width = this.adjustWidth(width, stations.length, options); 929 | 930 | // generate lines with points 931 | lines = this.makeLines(stations, width, height, options); 932 | legend = this.makeLegend(width, lines, options); 933 | endLines = this.makeEndLines(lines, options); 934 | lines = _.union(lines, endLines); 935 | 936 | // draw the svg map 937 | this.drawMap(lines, legend, width, height, options); 938 | }, 939 | 940 | translateCoordinates: function(x, y, direction, length){ 941 | var x_direction = 0, y_direction = 0; 942 | 943 | switch(direction){ 944 | case 'e': 945 | x_direction = 1; 946 | break; 947 | case 's': 948 | y_direction = 1; 949 | break; 950 | case 'w': 951 | x_direction = -1; 952 | break; 953 | } 954 | return { 955 | x: x + length * x_direction, 956 | y: y + length * y_direction 957 | }; 958 | } 959 | 960 | } 961 | 962 | export default tube; 963 | --------------------------------------------------------------------------------