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