Skip to content

Instantly share code, notes, and snippets.

@abubelinha
Forked from rengel-de/index.html
Created April 6, 2020 09:49
Show Gist options
  • Save abubelinha/4986f6bf7e015ba65cfcb5ced780a59b to your computer and use it in GitHub Desktop.
Save abubelinha/4986f6bf7e015ba65cfcb5ced780a59b to your computer and use it in GitHub Desktop.
Timeline for d3 - proof-of-concept

Timeline for d3 - proof-of-concept

This chart shows events, that have a defined start and/or end in the time continuum in form of a timeline or timechart. Events can be instants (one date only) or intervals (start date and end date).

The timeline consists of two bands.

The upper band shows the timeline items with data within the selected timeline interval. The lower band is the navigation band; it just shows the distribution of the items. The numbers in the lower band show the start, the length, and the end of the selected interval, respectively. Click on the lower band and drag to create a brush and select an interval.

Click on the brush and drag to move the interval. Click on the left or right border of the brush and drag to resize the interval. Click on the lower band outside the brush to restore the original view. Mouseover an item to show a tooltip.

Acknowledgements

This work was inspired by

'Simile Timeline' by 'David François Huynh' (http://www.simile-widgets.org/timeline/) and 'Swimlane Chart using d3.js' by Bill Bunkat (http://bl.ocks.org/bunkat/1962173).

The file structure

Any csv data file with the following line structure will do:

start,end,label ...,...,...

The first line contains the field names. The field names 'start', 'end', and 'label' in the first line are required. The following lines contain the data.

Intervals have a 'start', an 'end', and a label. The following example describes the lifespan of the philosopher Kant: 1724,1804,Kant

Instants have a 'start', an EMPTY 'end', and a label. The second comma is required to mark the 'end' field and to designate this event as a point. The following example gives the publication date of the 'Critique of Pure Reason' by Kant: 1781,,Critique of Pure Reason

Optional additional fields (i.e., 'description', 'image', 'link', etc.) are not used in this version, but will be in future versions.

'start' and 'end' either conform to the ISO data format YYYY-MM-DD or contain full years (that is: with century). BC dates and dates between 0..99 AD are handled correctly. For more information on 'start' and 'end' see the comment of the function 'parseDate'. 'label' ist a plain string. Example:

start,end,label 800 BC,701 BC,Homer 4 BC,65,Seneca 55,135,Epictetus 1469,1527,Machiavelli 1781,,Critique of Pure Reason ...

Limitations

I have developed this version as an exercise to learn a little bit of d3 and as a proof-of-concept for doing timelines.

This timeline doesn't look especially 'pretty'. I have chosen theses items (some poets and philosophers, some philosophical works) to show how a relatively long time span is displayed and how BC dates are handled. The dates are from the English Wikipedia Shorter intervals make for 'nicer' timelines, as you can see for yourself, if you use the brush.

Create your own timelines

To create your own timeline, you need

  1. A data file (see 'The file structure' above).

  2. The file 'timeline.js'; download and put into your working directory or on your path.

  3. The file 'timeline.css'; download and put into your working directory or on your path; change settings according to your preferences.

  4. Use 'index.html' (without comments) as a template and put in your filenames and paths.

Feedback

I'm still a d3 newbie. So feedback on doing things more the 'd3 way' is very welcome. Comments, suggestions, and bug reports are welcome, too.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="keywords" lang="de" content="Zeitleiste, Zeitlinie, Zeitkarte, Geschichte, Chronologie">
<meta name="keywords" lang="en" content="Timeline, Timemap, History, Chronology">
<title>Timeline - Proof-of-concept</title>
<!-- That's my local d3 path. When working locally, use your local path. -->
<!--<script src="../../../lib/d3/d3.v3.js"></script>-->
<!-- That's the 'official' path. Comment out, when working locally. -->
<script src="http://d3js.org/d3.v3.min.js"></script>
<!-- Store these two files in your application directory or on your path. -->
<script src="timeline.js"></script>
<link href="timeline.css" rel="stylesheet" type="text/css" />
</head>
<body>
<div id="timeline"></div>
<script>
/* You need a domElement, a sourceFile and a timeline.
The domElement will contain your timeline.
Use the CSS convention for identifying elements,
i.e. "div", "p", ".className", or "#id".
The sourceFile will contain your data.
If you prefer, you can also use tsv, xml, or json files
and the corresponding d3 functions for your data.
A timeline can have the following components:
.band(bandName, sizeFactor
bandName - string; the name of the band for references
sizeFactor - percentage; height of the band relation to the total height
Defines an area for timeline items.
A timeline must have at least one band.
Two bands are necessary, to change the selected time interval.
Three and Bands are allowed.
.xAxis(bandName)
bandName - string; the name of the band the xAxis will be attached to
Defines an xAxis for a band to show the range of the band.
This is optional, but highly recommended.
.labels(bandName)
bandName - string; the name of the band the labels will be attached to
Shows the start, length and end of the range of the band.
This is optional.
.tooltips(bandName)
bandName - string; the name of the band the labels will be attached to
Shows current start, length, and end of the selected interval of the band.
This is optional.
.brush(parentBand, targetBands]
parentBand - string; the band that the brush will be attached to
targetBands - array; the bands that are controlled by the brush
Controls the time interval of the targetBand.
Required, if you want to control/change the selected time interval
of one of the other bands.
.redraw()
Shows the initial view of the timeline.
This is required.
To make yourself familiar with these components try to
- comment out components and see what happens.
- change the size factors (second arguments) of the bands.
- rearrange the definitions of the components.
*/
// Define domElement and sourceFile
var domElement = "#timeline";
var sourceFile = "philosophers.csv";
// Read in the data and construct the timeline
d3.csv(sourceFile, function(dataset) {
timeline(domElement)
.data(dataset)
.band("mainBand", 0.82)
.band("naviBand", 0.08)
.xAxis("mainBand")
.tooltips("mainBand")
.xAxis("naviBand")
.labels("mainBand")
.labels("naviBand")
.brush("naviBand", ["mainBand"])
.redraw();
});
</script>
</body>
</html>
start end label
800 BC 701 BC Homer
750 BC 650 BC Hesiod
624 BC 546 BC Thales
610 BC 546 BC Anaximander
585 BC 525 BC Anaximenes
582 BC 496 BC Pythagoras
470 BC 380 BC Philolaus
428 BC 347 BC Archytas
570 BC 470 BC Xenophanes
510 BC 440 BC Parmenides
535 BC 475 BC Heraclitus
490 BC 430 BC Empedocles
500 BC 428 BC Anaxagoras
480 BC 420 BC Leucippus
460 BC 370 BC Democritus
490 BC 420 BC Protagoras
487 BC 376 BC Gorgias
371 BC 287 BC Theophrastus
469 BC 399 BC Socrates
450 BC 380 BC Euclid of Megara
445 BC 360 BC Antisthenes
435 BC 356 BC Aristippus
428 BC 347 BC Plato
405 BC 320 BC Diogenes of Sinope
396 BC 314 BC Xenocrates
384 BC 322 BC Aristotle
341 BC 270 BC Epicurus
287 BC 212 BC Archimedes
280 BC 207 BC Chrysippus
360 BC 280 BC Stilpo
334 BC 262 BC Zeno of Citium
187 BC 109 BC Clitomachus
106 BC 43 BC Cicero
94 BC 55 BC Lucretius
4 BC 65 Seneca
30 100 Musonius Rufus
45 120 Plutarch
55 135 Epictetus
70 156 Polycarp
121 180 Marcus Aurelius
150 215 Clement of Alexandria
160 210 Sextus Empiricus
205 270 Plotinus
232 304 Porphyry
270 351 Nicolaus of Myra
295 373 Athanasius
315 367 Hilarius of Poitiers
317 388 Themistius
329 390 Gregorius of Nazianz
330 379 Basilius
335 394 Gregorius of Nyssa
339 397 Ambrosius
340 420 Hieronymus
354 407 Chrisostomos
381 444 Cyrill
411 485 Proclus
462 540 Damascius
472 524 Boethius
1033 1109 Anselm of Canterbury
1091 1153 Bernhard of Clairvaux
1135 1204 Maimonides
1221 1274 Bonaventura
1225 1274 Thomas of Aquin
1235 1315 Rymundus Lullus
1193 1280 Albertus Magnus
1265 1321 Dante
1266 1308 Duns Scotus
1287 1347 Occam
1396 1484 Trapezuntius
1401 1464 Cusanus
1443 1485 Agricola
1452 1498 Savonarola
1467 1536 Erasmus
1469 1527 Machiavelli
1478 1535 Morus
1473 1543 Kopernikus
1483 1546 Luther
1484 1531 Zwingli
1486 1535 Agrippa
1493 1541 Paracelsus
1497 1560 Melanchton
1509 1564 Calvin
1540 1609 Scaliger
1546 1601 Brahe
1560 1626 Bacon
1564 1642 Galilei
1588 1679 Hobbes
1596 1659 Descartes
1623 1662 Pascal
1632 1677 Spinoza
1646 1716 Leibniz
1685 1735 Berkeley
1711 1776 Hume
1724 1804 Kant
1743 1819 Jacobi
1748 1832 Bentham
1770 1831 Hegel
1775 1854 Schelling
1762 1814 Fichte
1788 1860 Schopenhauer
1818 1883 Marx
1833 1921 Dühring
1833 1911 Dilthey
1844 1900 Nietzsche
1848 1925 Frege
1859 1952 Dewey
1859 1941 Bergson
1872 1970 Russell
1873 1958 Moore
1874 1928 Scheler
1882 1936 Schlick
1885 1977 Bloch
1889 1951 Wittgenstein
1890 1963 Ajdukiewicz
1900 1976 Ryle
1902 1995 Bochenski
1902 1994 Popper
1903 1993 Jonas
1903 1969 Adorno
1908 2000 Quine
1910 1989 Ayer
1911 1960 Austin
1921 2002 Rawls
1923 1991 Stegmüller
1931-10-04 2007-06-08 Rorty
760 BC Iliad
740 BC Odyssey
700 BC Theogony
1781 Critique of Pure Reason
1818 The World as Will and Representation
1739 A Treatise of Human Nature
1748 An Enquiry Concerning Human Understanding
1807 The Phenomenology of Spirit
1532 The Prince
380 BC The Republic
1651 Leviathan
1710 A Treatise Concerning the Principles of Human Knowledge
1637 Discourse on the Method
1883 Thus Spoke Zarathustra
/* axis */
.axis { /* axis labels */
fill: #808080;
font-family: sans-serif;
font-size: 10px;
}
.axis line{ /* axis tick marks */
stroke-width : 1;
stroke: grey;
shape-rendering: crispEdges;
}
.axis path { /* axis line */
stroke-width : 1;
stroke: grey;
shape-rendering: crispEdges;
}
/* timeline band */
.band { /* band background */
fill: #FAFAFA;
}
/* labels */
.bandLabel {
fill: #F0F0F0;
font: 10px sans-serif;
font-weight: bold;
}
.bandMinMaxLabel {
fill: blue;
font: 10px sans-serif;
font-weight: bold;
}
.bandMidLabel {
fill: red;
font: 10px sans-serif;
font-style: italic;
font-weight: bold;
}
/* brush */
.brush .extent {
stroke: gray;
fill: blue;
fill-opacity: .1;
}
.chart {
fill: #EEEEEE;
}
.interval {
fill: #AAFFFF;
stroke-width: 6;
cursor : default;
pointer-events: true;
}
.instant {
fill: #FFAAFF;
stroke-width: 6;
cursor : default;
/*pointer-events: true;*/
}
.instantLabel {
fill : blue;
font: 10px sans-serif;
shape-rendering: crispEdges;
}
.intervalLabel {
fill : black;
font: 10px sans-serif;
shape-rendering: crispEdges;
}
.item {
cursor : default;
pointer-events: auto;
}
.svg {
border-style: solid;
border-width: 1px;
border-color: black;
background-color: #FFFFFF;
}
.tooltip {
width: auto;
position: absolute;
visibility: hidden;
color : black;
cursor:default;
background-color: #FFFFEE;
border: 1px solid;
padding: 4px;
shape-rendering: crispEdges;
pointer-events: none;
}
// A timeline component for d3
// version v0.1
function timeline(domElement) {
//--------------------------------------------------------------------------
//
// chart
//
// chart geometry
var margin = {top: 20, right: 20, bottom: 20, left: 20},
outerWidth = 960,
outerHeight = 500,
width = outerWidth - margin.left - margin.right,
height = outerHeight - margin.top - margin.bottom;
// global timeline variables
var timeline = {}, // The timeline
data = {}, // Container for the data
components = [], // All the components of the timeline for redrawing
bandGap = 25, // Arbitray gap between to consecutive bands
bands = {}, // Registry for all the bands in the timeline
bandY = 0, // Y-Position of the next band
bandNum = 0; // Count of bands for ids
// Create svg element
var svg = d3.select(domElement).append("svg")
.attr("class", "svg")
.attr("id", "svg")
.attr("width", outerWidth)
.attr("height", outerHeight)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
svg.append("clipPath")
.attr("id", "chart-area")
.append("rect")
.attr("width", width)
.attr("height", height);
var chart = svg.append("g")
.attr("class", "chart")
.attr("clip-path", "url(#chart-area)" );
var tooltip = d3.select("body")
.append("div")
.attr("class", "tooltip")
.style("visibility", "visible");
//--------------------------------------------------------------------------
//
// data
//
timeline.data = function(items) {
var today = new Date(),
tracks = [],
yearMillis = 31622400000,
instantOffset = 100 * yearMillis;
data.items = items;
function showItems(n) {
var count = 0, n = n || 10;
console.log("\n");
items.forEach(function (d) {
count++;
if (count > n) return;
console.log(toYear(d.start) + " - " + toYear(d.end) + ": " + d.label);
})
}
function compareAscending(item1, item2) {
// Every item must have two fields: 'start' and 'end'.
var result = item1.start - item2.start;
// earlier first
if (result < 0) { return -1; }
if (result > 0) { return 1; }
// longer first
result = item2.end - item1.end;
if (result < 0) { return -1; }
if (result > 0) { return 1; }
return 0;
}
function compareDescending(item1, item2) {
// Every item must have two fields: 'start' and 'end'.
var result = item1.start - item2.start;
// later first
if (result < 0) { return 1; }
if (result > 0) { return -1; }
// shorter first
result = item2.end - item1.end;
if (result < 0) { return 1; }
if (result > 0) { return -1; }
return 0;
}
function calculateTracks(items, sortOrder, timeOrder) {
var i, track;
sortOrder = sortOrder || "descending"; // "ascending", "descending"
timeOrder = timeOrder || "backward"; // "forward", "backward"
function sortBackward() {
// older items end deeper
items.forEach(function (item) {
for (i = 0, track = 0; i < tracks.length; i++, track++) {
if (item.end < tracks[i]) { break; }
}
item.track = track;
tracks[track] = item.start;
});
}
function sortForward() {
// younger items end deeper
items.forEach(function (item) {
for (i = 0, track = 0; i < tracks.length; i++, track++) {
if (item.start > tracks[i]) { break; }
}
item.track = track;
tracks[track] = item.end;
});
}
if (sortOrder === "ascending")
data.items.sort(compareAscending);
else
data.items.sort(compareDescending);
if (timeOrder === "forward")
sortForward();
else
sortBackward();
}
// Convert yearStrings into dates
data.items.forEach(function (item){
item.start = parseDate(item.start);
if (item.end == "") {
//console.log("1 item.start: " + item.start);
//console.log("2 item.end: " + item.end);
item.end = new Date(item.start.getTime() + instantOffset);
//console.log("3 item.end: " + item.end);
item.instant = true;
} else {
//console.log("4 item.end: " + item.end);
item.end = parseDate(item.end);
item.instant = false;
}
// The timeline never reaches into the future.
// This is an arbitrary decision.
// Comment out, if dates in the future should be allowed.
if (item.end > today) { item.end = today};
});
//calculateTracks(data.items);
// Show patterns
//calculateTracks(data.items, "ascending", "backward");
//calculateTracks(data.items, "descending", "forward");
// Show real data
calculateTracks(data.items, "descending", "backward");
//calculateTracks(data.items, "ascending", "forward");
data.nTracks = tracks.length;
data.minDate = d3.min(data.items, function (d) { return d.start; });
data.maxDate = d3.max(data.items, function (d) { return d.end; });
return timeline;
};
//----------------------------------------------------------------------
//
// band
//
timeline.band = function (bandName, sizeFactor) {
var band = {};
band.id = "band" + bandNum;
band.x = 0;
band.y = bandY;
band.w = width;
band.h = height * (sizeFactor || 1);
band.trackOffset = 4;
// Prevent tracks from getting too high
band.trackHeight = Math.min((band.h - band.trackOffset) / data.nTracks, 20);
band.itemHeight = band.trackHeight * 0.8,
band.parts = [],
band.instantWidth = 100; // arbitray value
band.xScale = d3.time.scale()
.domain([data.minDate, data.maxDate])
.range([0, band.w]);
band.yScale = function (track) {
return band.trackOffset + track * band.trackHeight;
};
band.g = chart.append("g")
.attr("id", band.id)
.attr("transform", "translate(0," + band.y + ")");
band.g.append("rect")
.attr("class", "band")
.attr("width", band.w)
.attr("height", band.h);
// Items
var items = band.g.selectAll("g")
.data(data.items)
.enter().append("svg")
.attr("y", function (d) { return band.yScale(d.track); })
.attr("height", band.itemHeight)
.attr("class", function (d) { return d.instant ? "part instant" : "part interval";});
var intervals = d3.select("#band" + bandNum).selectAll(".interval");
intervals.append("rect")
.attr("width", "100%")
.attr("height", "100%");
intervals.append("text")
.attr("class", "intervalLabel")
.attr("x", 1)
.attr("y", 10)
.text(function (d) { return d.label; });
var instants = d3.select("#band" + bandNum).selectAll(".instant");
instants.append("circle")
.attr("cx", band.itemHeight / 2)
.attr("cy", band.itemHeight / 2)
.attr("r", 5);
instants.append("text")
.attr("class", "instantLabel")
.attr("x", 15)
.attr("y", 10)
.text(function (d) { return d.label; });
band.addActions = function(actions) {
// actions - array: [[trigger, function], ...]
actions.forEach(function (action) {
items.on(action[0], action[1]);
})
};
band.redraw = function () {
items
.attr("x", function (d) { return band.xScale(d.start);})
.attr("width", function (d) {
return band.xScale(d.end) - band.xScale(d.start); });
band.parts.forEach(function(part) { part.redraw(); })
};
bands[bandName] = band;
components.push(band);
// Adjust values for next band
bandY += band.h + bandGap;
bandNum += 1;
return timeline;
};
//----------------------------------------------------------------------
//
// labels
//
timeline.labels = function (bandName) {
var band = bands[bandName],
labelWidth = 46,
labelHeight = 20,
labelTop = band.y + band.h - 10,
y = band.y + band.h + 1,
yText = 15;
var labelDefs = [
["start", "bandMinMaxLabel", 0, 4,
function(min, max) { return toYear(min); },
"Start of the selected interval", band.x + 30, labelTop],
["end", "bandMinMaxLabel", band.w - labelWidth, band.w - 4,
function(min, max) { return toYear(max); },
"End of the selected interval", band.x + band.w - 152, labelTop],
["middle", "bandMidLabel", (band.w - labelWidth) / 2, band.w / 2,
function(min, max) { return max.getUTCFullYear() - min.getUTCFullYear(); },
"Length of the selected interval", band.x + band.w / 2 - 75, labelTop]
];
var bandLabels = chart.append("g")
.attr("id", bandName + "Labels")
.attr("transform", "translate(0," + (band.y + band.h + 1) + ")")
.selectAll("#" + bandName + "Labels")
.data(labelDefs)
.enter().append("g")
.on("mouseover", function(d) {
tooltip.html(d[5])
.style("top", d[7] + "px")
.style("left", d[6] + "px")
.style("visibility", "visible");
})
.on("mouseout", function(){
tooltip.style("visibility", "hidden");
});
bandLabels.append("rect")
.attr("class", "bandLabel")
.attr("x", function(d) { return d[2];})
.attr("width", labelWidth)
.attr("height", labelHeight)
.style("opacity", 1);
var labels = bandLabels.append("text")
.attr("class", function(d) { return d[1];})
.attr("id", function(d) { return d[0];})
.attr("x", function(d) { return d[3];})
.attr("y", yText)
.attr("text-anchor", function(d) { return d[0];});
labels.redraw = function () {
var min = band.xScale.domain()[0],
max = band.xScale.domain()[1];
labels.text(function (d) { return d[4](min, max); })
};
band.parts.push(labels);
components.push(labels);
return timeline;
};
//----------------------------------------------------------------------
//
// tooltips
//
timeline.tooltips = function (bandName) {
var band = bands[bandName];
band.addActions([
// trigger, function
["mouseover", showTooltip],
["mouseout", hideTooltip]
]);
function getHtml(element, d) {
var html;
if (element.attr("class") == "interval") {
html = d.label + "<br>" + toYear(d.start) + " - " + toYear(d.end);
} else {
html = d.label + "<br>" + toYear(d.start);
}
return html;
}
function showTooltip (d) {
var x = event.pageX < band.x + band.w / 2
? event.pageX + 10
: event.pageX - 110,
y = event.pageY < band.y + band.h / 2
? event.pageY + 30
: event.pageY - 30;
tooltip
.html(getHtml(d3.select(this), d))
.style("top", y + "px")
.style("left", x + "px")
.style("visibility", "visible");
}
function hideTooltip () {
tooltip.style("visibility", "hidden");
}
return timeline;
};
//----------------------------------------------------------------------
//
// xAxis
//
timeline.xAxis = function (bandName, orientation) {
var band = bands[bandName];
var axis = d3.svg.axis()
.scale(band.xScale)
.orient(orientation || "bottom")
.tickSize(6, 0)
.tickFormat(function (d) { return toYear(d); });
var xAxis = chart.append("g")
.attr("class", "axis")
.attr("transform", "translate(0," + (band.y + band.h) + ")");
xAxis.redraw = function () {
xAxis.call(axis);
};
band.parts.push(xAxis); // for brush.redraw
components.push(xAxis); // for timeline.redraw
return timeline;
};
//----------------------------------------------------------------------
//
// brush
//
timeline.brush = function (bandName, targetNames) {
var band = bands[bandName];
var brush = d3.svg.brush()
.x(band.xScale.range([0, band.w]))
.on("brush", function() {
var domain = brush.empty()
? band.xScale.domain()
: brush.extent();
targetNames.forEach(function(d) {
bands[d].xScale.domain(domain);
bands[d].redraw();
});
});
var xBrush = band.g.append("svg")
.attr("class", "x brush")
.call(brush);
xBrush.selectAll("rect")
.attr("y", 4)
.attr("height", band.h - 4);
return timeline;
};
//----------------------------------------------------------------------
//
// redraw
//
timeline.redraw = function () {
components.forEach(function (component) {
component.redraw();
})
};
//--------------------------------------------------------------------------
//
// Utility functions
//
function parseDate(dateString) {
// 'dateString' must either conform to the ISO date format YYYY-MM-DD
// or be a full year without month and day.
// AD years may not contain letters, only digits '0'-'9'!
// Invalid AD years: '10 AD', '1234 AD', '500 CE', '300 n.Chr.'
// Valid AD years: '1', '99', '2013'
// BC years must contain letters or negative numbers!
// Valid BC years: '1 BC', '-1', '12 BCE', '10 v.Chr.', '-384'
// A dateString of '0' will be converted to '1 BC'.
// Because JavaScript can't define AD years between 0..99,
// these years require a special treatment.
var format = d3.time.format("%Y-%m-%d"),
date,
year;
date = format.parse(dateString);
if (date !== null) return date;
// BC yearStrings are not numbers!
if (isNaN(dateString)) { // Handle BC year
// Remove non-digits, convert to negative number
year = -(dateString.replace(/[^0-9]/g, ""));
} else { // Handle AD year
// Convert to positive number
year = +dateString;
}
if (year < 0 || year > 99) { // 'Normal' dates
date = new Date(year, 6, 1);
} else if (year == 0) { // Year 0 is '1 BC'
date = new Date (-1, 6, 1);
} else { // Create arbitrary year and then set the correct year
// For full years, I chose to set the date to mid year (1st of July).
date = new Date(year, 6, 1);
date.setUTCFullYear(("0000" + year).slice(-4));
}
// Finally create the date
return date;
}
function toYear(date, bcString) {
// bcString is the prefix or postfix for BC dates.
// If bcString starts with '-' (minus),
// if will be placed in front of the year.
bcString = bcString || " BC" // With blank!
var year = date.getUTCFullYear();
if (year > 0) return year.toString();
if (bcString[0] == '-') return bcString + (-year);
return (-year) + bcString;
}
return timeline;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment