├── 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 | 
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 |
--------------------------------------------------------------------------------