├── README.md ├── demo.html └── timeline.js /README.md: -------------------------------------------------------------------------------- 1 | # relational timeline 2 | 3 | A timeline designed to help present a sequence of events occurring over time involving several different entities. 4 | 5 | Demo: http://codepen.io/pcbje/pen/ZBJrpP 6 | 7 | ### Usage: 8 | 9 | ```javascript 10 | timeline.create('timeline') // ID of HTML element. 11 | .config({min_date: '2015-01-01', max_date: '2016-12-31', ...}) 12 | .event_types(['Chat']) // [''] 13 | .nodes([{'id': 'Petter', 'type': 'Person'}, {'id': 'Karl', 'type': 'Person'}]) 14 | .events([{type: 'Chat', timestamp: '2016-01-03', source: 'Petter', target: 'Karl', label: 'Label label'}]) 15 | .ticks(); 16 | ``` 17 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | relational timeline 8 | 47 | 162 | 163 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /timeline.js: -------------------------------------------------------------------------------- 1 | var timeline = (function() { 2 | var node_cache = {}; 3 | var type_cache = {}; 4 | var svg; 5 | var used; 6 | 7 | var config = { 8 | fontsize: 13, 9 | padding: 20, 10 | margin: 4, 11 | label_width: 120, 12 | timeline_spacing: 20, 13 | min_date: '2015-01-01', 14 | max_date: '2016-12-31', 15 | circle_size: 8, 16 | overlap: true, 17 | curved: true, 18 | colored_arcs: true, 19 | arc_opacity: 0.3, 20 | circle_opacity: 0.9, 21 | event_label_opacity: 0.75, 22 | colors: ['#293757', '#568D4B', '#D5BB56', '#D26A1B', '#A41D1A'], 23 | label_space: 130, 24 | ticks: 'month' 25 | } 26 | 27 | var set_config = function(new_config) { 28 | for (var key in new_config) { 29 | config[key] = new_config[key] 30 | } 31 | return this; 32 | } 33 | 34 | var padd = function(num) { 35 | if (num*1 >= 10) return num; 36 | return '0' + num; 37 | } 38 | 39 | var format_date = function(date) { 40 | return date.getFullYear() + '-' + padd(date.getMonth() + 1) + '-' + padd(date.getDate()); 41 | } 42 | 43 | var create = function(id) { 44 | svg = SVG(id).size(document.body.clientWidth, 100) 45 | return this; 46 | } 47 | 48 | var nodes = function(nodes) { 49 | used = 0; 50 | var max_width = 0; 51 | var node_labels = []; 52 | node_cache = {}; 53 | 54 | nodes.map(function(node, idx) { 55 | if (node.type == 'space') { 56 | var height = 15; 57 | 58 | var y = used + config.label_space 59 | 60 | svg.rect(svg.width(), height).move(0, y).fill('#000').attr({'opacity': 0.15}) 61 | svg.plain(node.id).font({family: 'Helvetica', size: config.fontsize * 0.8, anchor: 'start', fill: '#000'}).move(3, y+2) 62 | 63 | used += height; 64 | prev_space = true; 65 | } 66 | else { 67 | var height = config.fontsize + config.padding; 68 | if (!config.overlap) { 69 | height = Math.max(height, Object.keys(type_cache).length * (config.circle_size+ 2) + config.padding / 2) 70 | } 71 | var pos_y = used + config.label_space; 72 | used += height + config.margin; 73 | node.y = pos_y + config.margin; 74 | var text_y = pos_y + (height - config.fontsize) / 4 75 | var text = svg.plain(node.id).move(0, text_y) 76 | 77 | node_labels.push(text); 78 | text.font({family: 'Helvetica', size: config.fontsize, anchor: 'end'}) 79 | max_width = Math.max(max_width, text.node.getBBox().width) 80 | svg.height(pos_y + height + config.timeline_spacing + config.label_space) 81 | node.idx = Object.keys(node_cache).length 82 | node_cache[node.id] = node; 83 | 84 | if (!prev_space) { 85 | svg.line(0, pos_y, svg.width(), pos_y).stroke({ width: 1, opacity: 0.1 }) 86 | } 87 | prev_space = false; 88 | } 89 | }); 90 | 91 | config.label_width = max_width; 92 | 93 | node_labels.map(function(node_label) { 94 | node_label.x(config.padding + config.label_width) 95 | }); 96 | 97 | svg.line(0, config.label_space+ used, svg.width(), config.label_space+ used).stroke({ width: 1, opacity: 0.1 }) 98 | 99 | return this; 100 | } 101 | 102 | var event_types = function(event_types) { 103 | var x = svg.width() - 10; 104 | 105 | type_cache = {}; 106 | 107 | event_types.map(function(type, idx) { 108 | type_cache[type] = idx; 109 | var text = svg.plain(type).font({family: 'Helvetica', size: config.fontsize * 0.9, anchor: 'start'}) 110 | x -= text.node.getBBox().width + 10 111 | text.move(x, 20) 112 | x -= 14 113 | svg.circle(10).move(x, 21).fill(config.colors[idx % config.colors.length]).attr({'opacity': config.circle_opacity}); 114 | x -= config.margin; 115 | }); 116 | 117 | return this; 118 | } 119 | 120 | var ticks = function() { 121 | var start = new Date(config.min_date).getTime(); 122 | var end = new Date(config.max_date).getTime(); 123 | 124 | var current = start; 125 | var tick_size = 60 * 60 * 24 * 1000; 126 | 127 | var ticks = []; 128 | while (current < end) { 129 | var tick = new Date(current); 130 | 131 | if (config.ticks == 'day') { 132 | ticks.push(tick.getTime()) 133 | } 134 | 135 | else if (config.ticks == 'week' && tick.getDay() == 0) { 136 | ticks.push(tick.getTime()) 137 | } 138 | 139 | else if (config.ticks == 'month' && tick.getDate() == 1) { 140 | ticks.push(tick.getTime()) 141 | } 142 | 143 | current += tick_size; 144 | } 145 | 146 | var base_x = config.label_width + 2 * config.padding + config.margin; 147 | 148 | svg.line(base_x, config.label_space, base_x, config.label_space + used).stroke({ width: 1, opacity: 0.1 }) 149 | 150 | ticks.map(function(tick) { 151 | if (tick < start || tick > end) return; 152 | var pos_x = ((tick - start) / (end - start)) * (svg.width() - config.label_width) + base_x; 153 | svg.line(pos_x, config.label_space, pos_x, config.label_space + used).stroke({ width: 1, opacity: 0.1 }) 154 | }); 155 | 156 | return this; 157 | } 158 | 159 | var events = function(events) { 160 | var event_labels = []; 161 | var min = new Date(config.min_date).getTime(); 162 | var max = new Date(config.max_date).getTime(); 163 | 164 | events.map(function(event) { 165 | if (!(event.type in type_cache)) return; 166 | 167 | var time = new Date(event.timestamp).getTime(); 168 | color = config.colors[type_cache[event.type] % config.colors.length] 169 | 170 | var pos_y = node_cache[event.source].y + config.circle_size; 171 | var pos_x = ((time - min) / (max - min)) * (svg.width() - config.label_width) + config.label_width + 2 * config.padding + config.margin; 172 | 173 | if (!config.overlap) { 174 | pos_y = node_cache[event.source].y + (config.circle_size + 2) * type_cache[event.type]; 175 | } 176 | 177 | if (event.target) { 178 | var from_x = pos_x + config.circle_size / 2 179 | var from_y = pos_y + config.circle_size / 2 180 | var to_x = pos_x + (config.circle_size / 4) 181 | var to_y = node_cache[event.target].y + config.circle_size; 182 | 183 | if (!config.overlap) { 184 | to_y = node_cache[event.target].y + (config.circle_size + 2) * type_cache[event.type]; 185 | } 186 | 187 | var arc = config.curved ? 188 | svg.path().M(from_x, from_y).A(3, 20, 0, 0, 0, {x:to_x, y:to_y}) 189 | : svg.line(from_x, from_y, from_x, to_y); 190 | 191 | arc.attr({ fill: 'none', stroke: config.colored_arcs ? color : '#000', 'stroke-width': 1, 'opacity': config.arc_opacity}) 192 | svg.circle(config.circle_size / 2).move(to_x, to_y).fill(color).attr({'opacity': Math.min(config.arc_opacity * 10, config.circle_opacity)}); 193 | } 194 | 195 | if (event.end) { 196 | var to_pos_x = ((new Date(event.end).getTime() - min) / (max - min)) * (svg.width() - config.label_width) + config.label_width + 2 * config.padding + config.margin; 197 | svg.rect(to_pos_x - pos_x, config.circle_size).move(pos_x, pos_y).fill(color).attr({'opacity': config.circle_opacity}).radius(3); 198 | } 199 | else { 200 | svg.circle(config.circle_size).move(pos_x, pos_y).fill(color).attr({'opacity': config.circle_opacity}); 201 | } 202 | 203 | if (event.label) { 204 | var label_x = pos_x - 15; 205 | 206 | var dir = (node_cache[event.source].idx / Object.keys(node_cache).length).toFixed(0) * 1; 207 | 208 | if (event.legen_dir == 'down') { 209 | dir = 1; 210 | } 211 | 212 | var arr; 213 | 214 | // Going down 215 | if (dir === 1) { 216 | label_y = svg.height() - config.label_space; 217 | arr = [ 218 | [pos_x, pos_y+config.circle_size], 219 | [label_x + 5, pos_y+config.circle_size+10], 220 | [label_x + 5, label_y] 221 | ] 222 | } 223 | // Going up 224 | else { 225 | var label_y = config.label_space - 40; 226 | arr = [ 227 | [pos_x, pos_y], 228 | [label_x + 5, pos_y-10], 229 | [label_x + 5, label_y + 20] 230 | ] 231 | } 232 | 233 | var txt = format_date(new Date(event.timestamp)) + ': ' + event.label; 234 | 235 | if (event.end) { 236 | txt = format_date(new Date(event.timestamp)) + ' to ' + format_date(new Date(event.end))+ ': ' + event.label 237 | } 238 | 239 | var text = svg.plain(txt).move(label_x, label_y).font({family: 'Helvetica', size: config.fontsize * 0.9, anchor: 'start'}).attr({'opacity': config.event_label_opacity}) 240 | var poly = svg.polyline(arr).fill('none').stroke({ width: 1, 'dasharray': '2, 2', 'color': '#888' }).attr({'opacity': config.event_label_opacity}) 241 | 242 | event_labels.push({text: text, dir: dir, poly: poly}) 243 | } 244 | }); 245 | 246 | var added = []; 247 | 248 | event_labels.reverse().map(function(label, a) { 249 | var r1 = label.text.node.getBoundingClientRect(); 250 | added.map(function(other, b) { 251 | var r2 = other.text.node.getBoundingClientRect(); 252 | 253 | var collision = !(r2.left > r1.right || r2.right < r1.left || r2.top > r1.bottom || r2.bottom < r1.top); 254 | 255 | while (collision) { 256 | var delta = label.dir == 1 ? -1 : 1; 257 | var arr = label.poly.array().value; 258 | arr[arr.length-1][1] -= 20*delta 259 | label.poly.plot(arr) 260 | label.text.y(label.text.y() - 20*delta); 261 | r1 = label.text.node.getBoundingClientRect(); 262 | collision = !(r2.left > r1.right || r2.right < r1.left || r2.top > r1.bottom || r2.bottom < r1.top); 263 | } 264 | }); 265 | 266 | added.push(label) 267 | }); 268 | 269 | return this; 270 | } 271 | 272 | return { 273 | create: create, 274 | nodes: nodes, 275 | event_types: event_types, 276 | ticks: ticks, 277 | events: events, 278 | config: set_config 279 | } 280 | })(); 281 | --------------------------------------------------------------------------------