gojs
Version:
Interactive diagrams, charts, and graphs, such as trees, flowcharts, orgcharts, UML, BPMN, or business diagrams
849 lines (848 loc) • 36.9 kB
HTML
<html>
<head>
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-1506307-6"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-1506307-6');
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GoTimeline</title>
<meta name="description" content="" />
<meta charset="UTF-8">
<script src="./go.js"></script>
<script src="./goeditor-data-interaction.js"></script>
<script src="./goeditor-setup.js"></script>
<script src="./storage/gcs.js"></script>
<script src="https://apis.google.com/js/api.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dropbox.js/2.5.7/Dropbox-sdk.min.js"></script>
<script src="https://js.live.net/v7.2/OneDrive.js"></script>
<script type="text/javascript" src="https://www.dropbox.com/static/api/2/dropins.js" id="dropboxjs" data-app-key="3sm2ko6q7u1gbix"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.3/jquery-ui.min.js"></script>
<script src="./DataInspector.js"></script>
<link rel="stylesheet" type="text/css" href="./DataInspector.css" />
<link rel="stylesheet" type="text/css" href="./storage/GoCloudStorageUI.css" />
<link rel="stylesheet" type="text/css" href="./goeditor.css" />
<script>
function init() {
setupEditorApplication();
nodeGenerationMap = new go.Map();
categories = [""];
var diagramContextMenu =
$(go.Adornment, "Vertical",
$("ContextMenuButton",
{
click: function (e, obj) {
createNode("Event")
}
},
$(go.TextBlock, "Add Event")
),
$("ContextMenuButton",
{
click: function (e, obj) {
createNode("Range")
}
},
$(go.TextBlock, "Add Range")
)
);
myDiagram.layout = $(TimelineLayout);
myDiagram.contextMenu = diagramContextMenu;
myDiagram.isTreePathToChildren = false;
myDiagram.initialContentAlignment = go.Spot.Center;
myDiagram.animationManager.isEnabled = false;
// when a drag begins, if an event node is being dragged, disable the diagram layout until the drag ends
myDiagram.toolManager.draggingTool.doActivate = function () {
var tool = this;
go.DraggingTool.prototype.doActivate.call(tool);
var draggedPartsIncludeEvent = false;
tool.draggedParts.iterator.each(function (kvp){
var part = kvp.key;
if (part instanceof go.Node && part.category == "Event" || part.category == "Range") {
draggedPartsIncludeEvent = true;
}
});
if (draggedPartsIncludeEvent) {
tool.diagram.layout.isOngoing = false;
}
}
// when a drag is completed, if an event node was dragged, relayout diagram
myDiagram.toolManager.draggingTool.doDeactivate = function () {
var tool = this;
var draggedPartsIncludeEvent = false;
tool.draggedParts.iterator.each(function(kvp){
var part = kvp.key;
if (part instanceof go.Node && part.category == "Event" || part.category == "Range") {
draggedPartsIncludeEvent = true;
var timeline = myDiagram.findNodeForKey("timeline");
if (part.location.y > timeline.location.y) {
tool.diagram.model.set(part.data, "isPositionedBelow", true);
} else {
tool.diagram.model.set(part.data, "isPositionedBelow", false);
}
}
});
if (draggedPartsIncludeEvent) {
tool.diagram.layout.isOngoing = true;
tool.diagram.layout.doLayout(tool.diagram.nodes);
}
go.DraggingTool.prototype.doDeactivate.call(tool);
}
// context menu for event nodes
var eventNodeContextMenu =
$(go.Adornment, "Vertical",
$("ContextMenuButton",
{
click: function(e, obj) {
var part = obj.part.adornedPart;
if (part.data.isPositionedBelow == undefined) part.data.isPositionedBelow = true;
else part.data.isPositionedBelow = !part.data.isPositionedBelow;
part.updateTargetBindings();
myDiagram.layout.doLayout(myDiagram.nodes);
}
},
$(go.TextBlock, "Position Below",
new go.Binding("text", "", function(obj) {
var node = obj.part.adornedPart;
return node.data.isPositionedBelow ? "Position Above" : "Position Below";
}).ofObject()
),
),
$("ContextMenuButton",
{
click: function(e, obj) {
var part = obj.part.adornedPart;
myDiagram.remove(part);
}
},
$(go.TextBlock, "Delete"))
);
// Event nodes -- default node template
myDiagram.nodeTemplateMap.add("Event",
$(go.Node, "Table",
{ locationSpot: go.Spot.Center, movable: true,
contextMenu: eventNodeContextMenu,
dragComputation: function(part,pt,gridpt) {
if (pt.x === part.location.x) {
return pt;
}
// figure out how far along the line we are now
var link = part.findLinksOutOf().first();
var ptOnLine = null;
link.points.iterator.each(function(p) {
if (p.y === 0) ptOnLine = p;
});
var timeline = myDiagram.findNodeForKey("timeline");
var line = timeline.findObject("MAIN");
var totalLineWidth = line.actualBounds.width;
var x1 = line.actualBounds.x;
var x2 = line.actualBounds.x + line.actualBounds.width;
// how far along the line is ptOnLine?
var ptDistAlong = ptOnLine.x - x1;
var ptFracAlong = ptDistAlong / totalLineWidth;
var dfd = getDateForDist(ptDistAlong);
var newDate = makeLocalDate(dfd);
newDate.setHours(0,0,0);
myDiagram.model.set(part.data, "date", newDate);
return pt;
},
toolTip: // define a tooltip for each node that displays the color as text
$(go.Adornment, "Auto",
$(go.Shape, { fill: "#FFFFCC" }),
$(go.TextBlock, { margin: 4 },
new go.Binding("text", "description"))
) // end of Adornment
},
new go.Binding("location", "loc").makeTwoWay(),
$(go.Panel, "Auto", //{ maxSize: new go.Size(100,NaN) },
$(go.Shape, "RoundedRectangle",
{ fill: "#252526", stroke: "#519ABA", strokeWidth: 2 }
),
$(go.Panel, "Table",
$(go.TextBlock,
{
maxSize: new go.Size(100,100),
row: 0,
stroke: "#CCCCCC",
font: "bold 10pt sans-serif",
textAlign: "center", margin: 2
},
new go.Binding("text", "event")
),
$(go.TextBlock,
{
maxSize: new go.Size(100,100),
row: 1,
stroke: "#A074C4",
textAlign: "center", margin: 2
},
new go.Binding("text", "date", function(d) {
return d.toLocaleDateString();
})
)
)
)
));
myDiagram.toolManager.resizingTool.resize = function(newr) {
var side = this.handle.name;
var node = this.adornedObject.part;
if (node.category == "Range") {
var pt = this.diagram.lastInput.documentPoint;
var timeline = this.diagram.findNodeForKey("timeline").findObject("MAIN");
if (side === "L" || side === "R") {
// how far along the timeline (in document units) is the dragging handle?
var x1 = timeline.actualBounds.x; var y1 = timeline.actualBounds.y;
var x2 = timeline.actualBounds.x+timeline.actualBounds.width; var y2 = y1;
pt.projectOntoLineSegment(x1, y1, x2, y2);
var daysDist = convertDocumentUnitsToDays(pt.x);
var timelineNode = this.diagram.findNodeForKey("timeline");
var tlStart = new Date(timelineNode.data.start);
var newDate = tlStart;
newDate.setDate(newDate.getDate() + daysDist);
newDate = new Date(newDate);
if (side == "R" && newDate < new Date(node.data.start)) {
newDate = new Date(node.data.start);
} else if (side == "L" && newDate > new Date(node.data.end)) {
newDate = new Date(node.data.end);
}
var prop = (side == "R") ? "end" : "start";
this.diagram.model.set(node.data, prop, newDate);
this.diagram.layout.doLayout(this.diagram.nodes);
}
// adjusting range height
else {
if (pt.y > timeline.actualBounds.y && !node.data.isPositionedBelow) return;
if (pt.y < timeline.actualBounds.y && node.data.isPositionedBelow) return;
var yDistFromLine = Math.abs(pt.y-timeline.actualBounds.y);
var rh = yDistFromLine < 30 ? 30 : yDistFromLine;
this.diagram.model.set(node.data, "rangeHeight", rh);
}
}
else {
go.ResizingTool.prototype.resize.call(this, newr);
}
}
// Event range nodes
lastDraggedPoint = new go.Point(0,0);
myDiagram.nodeTemplateMap.add("Range",
$(go.Node, "Vertical",
{
locationSpot: go.Spot.BottomCenter, locationObjectName: "RANGELINEABOVE",
contextMenu: eventNodeContextMenu, movable: true,
resizable: true, resizeObjectName: "RANGELINEABOVE", selectionAdorned: false,
resizeAdornmentTemplate:
$(go.Adornment, "Spot",
$(go.Placeholder), // takes size and position of adorned object
$(go.Shape, "TriangleLeft", // left resize handle
{ alignment: go.Spot.Left, cursor: "col-resize", name: "L",
desiredSize: new go.Size(8, 8), fill: "lightblue", stroke: "black" },
new go.Binding("fill", "color")
),
$(go.Shape, "TriangleRight", // right resize handle
{ alignment: go.Spot.Right, cursor: "col-resize", name: "R",
desiredSize: new go.Size(8, 8), fill: "lightblue", stroke: "black" },
new go.Binding("fill", "color")
),
$(go.Shape, "Diamond", // up resize handle
{ alignment: go.Spot.BottomCenter, cursor: "ns-resize", name: "U",
desiredSize: new go.Size(8, 8), fill: "lightblue", stroke: "black" },
new go.Binding("fill", "color")
)
),
dragComputation: function (part, pt, gridpt) {
if (myDiagram.lastInput.documentPoint.x == lastDraggedPoint.x) {
return new go.Point(pt.x, part.location.y);
}
lastDraggedPoint = myDiagram.lastInput.documentPoint;
var oldLoc = part.location;
var rangeWidth = part.data.rangeWidth;
var l = part.data.isPositionedBelow ? part.findObject("RANGELINEBELOW") : part.findObject("RANGELINEABOVE");
var sPtx = l.getDocumentPoint(go.Spot.Left).x; var ePtX = l.getDocumentPoint(go.Spot.Right).x;
//var startX = part.actualBounds.x;
startX = sPtx;
var xOff = pt.x-oldLoc.x;
var xVal = startX + (.5*rangeWidth) + xOff;
var newLoc = new go.Point(xVal, oldLoc.y);
part.location = newLoc;
// gets the date at each end of the range node
function getDateForEndpoint(pt) {
var line = myDiagram.findNodeForKey("timeline").findObject("MAIN");
var lineStart = new go.Point(line.actualBounds.x, line.actualBounds.y);
var lineEnd = new go.Point(line.actualBounds.x + line.actualBounds.width, line.actualBounds.y);
var totalLineWidth = lineEnd.x - lineStart.x;
// how far along the line is ptOnLine?
var ptOnLine = pt.projectOntoLineSegmentPoint(lineStart, lineEnd);
var ptDistAlong = ptOnLine.x - lineStart.x;
var ptFracAlong = ptDistAlong / totalLineWidth;
var d = new Date (getDateForDist(ptDistAlong));
return d;
}
// just update start / end dates based on the bounds of the part, given by the newLoc
var sPt = new go.Point(sPtx, part.location.y);
var ePt = new go.Point(ePtX, part.location.y);
var newStartDate = getDateForEndpoint(sPt);
var newEndDate = getDateForEndpoint(ePt);
myDiagram.model.set(part.data, "start", newStartDate);
myDiagram.model.set(part.data, "end", newEndDate);
return newLoc;
},
toolTip: // define a tooltip for each node that displays the color as text
$(go.Adornment, "Auto",
$(go.Shape, { fill: "#FFFFCC" }),
$(go.TextBlock, { margin: 4 },
new go.Binding("text", "description"))
) // end of Adornment
},
new go.Binding("location", "loc").makeTwoWay(),
new go.Binding("locationSpot", "isPositionedBelow", function(ipb){
return ipb ? go.Spot.TopCenter : go.Spot.BottomCenter;
}),
new go.Binding("locationObjectName", "isPositionedBelow", function(ipb){
return ipb ? "RANGELINEBELOW" : "RANGELINEABOVE";
}),
new go.Binding("resizeObjectName", "isPositionedBelow", function(ipb){
return ipb ? "RANGELINEBELOW" : "RANGELINEABOVE";
}),
// dates + name, above
$(go.Panel, "Vertical", { margin: 0, background: "black", cursor: "move" },
$(go.TextBlock, "Event Range Name", { stroke: "white", name: "RANGETEXTABOVE" },
new go.Binding("visible", "isPositionedBelow", function (ipb){
return !ipb;
}),
new go.Binding("text", "event")
),
$(go.TextBlock, "dates range", { stroke: "white", name: "RANGEDATESABOVE" },
new go.Binding("text", "", function(obj){
var part = obj.part;
var sd = new Date(obj.part.data.start); var ed = new Date(obj.part.data.end);
return sd.toLocaleDateString() + " - " + ed.toLocaleDateString();
}).ofObject(),
new go.Binding("visible", "isPositionedBelow", function (ipb){
return !ipb;
})
),
// the range shape -- leftmost point must be at start date, rightmost point at end date
$(go.Shape, "LineH", { stroke: "red", strokeWidth: 5, height: 0, alignment: go.Spot.Center, name: "RANGELINEABOVE", /*geometryString: "M0 480 C 148 320 183 450 285 419 S 335 274 392 391 S 583 320 730 480 "*/ },
new go.Binding("width", "rangeWidth").makeTwoWay(),
new go.Binding("stroke", "color"),
new go.Binding("visible", "isPositionedBelow", function (ipb){
return !ipb;
})
)
), // end dates + name, above
// the range "rectangle" -- visible iff range is positioned above the timeline
$(go.Shape, "Rectangle", { opacity: .05, stroke: null, name: "RANGESHAPEABOVE", pickable: false },
new go.Binding("width", "rangeWidth"),
new go.Binding("height", "rangeHeight").makeTwoWay(),
new go.Binding("fill", "color"),
new go.Binding("visible", "isPositionedBelow", function (ipb){
return !ipb;
})
),
// the range "rectangle" -- visible iff range is positioned below the timeline
$(go.Shape, "Rectangle", { opacity: .2, name: "RANGESHAPEBELOW" },
new go.Binding("width", "rangeWidth").makeTwoWay(),
new go.Binding("height", "rangeHeight").makeTwoWay(),
new go.Binding("fill", "color"),
new go.Binding("visible", "isPositionedBelow", function (ipb){
return ipb;
})
),
$(go.Panel, "Vertical", { name: "RANGEDETAILBELOW", background: "black", cursor: "move" },
$(go.Shape, "LineH", { stroke: "red", strokeWidth: 5, alignment: go.Spot.Center, height: 0, name: "RANGELINEBELOW" },
new go.Binding("width", "rangeWidth").makeTwoWay(),
new go.Binding("stroke", "color"),
new go.Binding("visible", "isPositionedBelow", function (ipb){
return ipb;
})
),
$(go.TextBlock, "Event Range Name", { stroke: "white", name: "RANGETEXTBELOW" },
new go.Binding("visible", "isPositionedBelow", function (ipb){
return ipb;
}),
new go.Binding("text", "event")
),
$(go.TextBlock, "dates range", { stroke: "white", name: "RANGEDATESBELOW" },
new go.Binding("text", "", function(obj){
var part = obj.part;
var sd = new Date(obj.part.data.start); var ed = new Date(obj.part.data.end);
return sd.toLocaleDateString() + " - " + ed.toLocaleDateString();
}).ofObject(),
new go.Binding("visible", "isPositionedBelow", function (ipb){
return ipb;
})
)
)
)
);
var timelineContextMenu =
$(go.Adornment, "Vertical",
$("ContextMenuButton",
{
click: function (e, obj) {
createEventNode();
}
},
$(go.TextBlock, "Add Event")
)
);
// timeline node
myDiagram.nodeTemplateMap.add("Line",
$(go.Node, "Graduated",
{
movable: false, copyable: false, deletable: false,
resizable: true, resizeObjectName: "MAIN",
background: "transparent",
graduatedMin: 0,
graduatedMax: 365,
graduatedTickUnit: 1,
resizeAdornmentTemplate: // only resizing at right end
$(go.Adornment, "Spot",
$(go.Placeholder),
$(go.Shape, { alignment: go.Spot.Right, cursor: "e-resize", desiredSize: new go.Size(4, 16), fill: "lightblue", stroke: "deepskyblue" })
),
contextMenu: diagramContextMenu
},
new go.Binding("graduatedMax", "", timelineDays),
$(go.Shape, "LineH",
{ name: "MAIN", stroke: "#519ABA", height: 1, strokeWidth: 3, portId: "" },
new go.Binding("width", "length").makeTwoWay()
),
$(go.Shape, { geometryString: "M0 0 V10", interval: 7, stroke: "#519ABA", strokeWidth: 2 },
new go.Binding("interval", "length", calculateLabelInterval)
),
$(go.TextBlock,
{
font: "10pt sans-serif",
stroke: "#CCCCCC",
interval: 14,
alignmentFocus: go.Spot.MiddleRight,
segmentOrientation: go.Link.OrientMinus90,
segmentOffset: new go.Point(0, 12),
graduatedFunction: valueToDate,
name: "LABEL"
},
new go.Binding("interval", "length", calculateLabelInterval)
)
)
);
myDiagram.addDiagramListener("ViewportBoundsChanged", function(e) {
var diagram = e.diagram;
var timeline = diagram.findNodeForKey("timeline");
timeline.updateTargetBindings("length");
});
function calculateLabelInterval(len) {
var timeline = myDiagram.findNodeForKey("timeline");
// how many days does len represent?
var daysInTimeline = Math.round((timeline.data.end-timeline.data.start)/(1000*60*60*24));
var daysPerPixel = daysInTimeline / len;
var daysLen = len*daysPerPixel;
var numIntervals = len/125;
var daysPerInterval = daysLen/(numIntervals*myDiagram.scale);
if (daysPerInterval < 1) daysPerInterval = 1;
return daysPerInterval;
}
// The template for the link connecting the event node with the timeline bar node:
myDiagram.linkTemplate =
$(BarLink, // defined below
{ toShortLength: 2 , layerName: "Background" },
$(go.Shape, { stroke: "#E37933", strokeWidth: 2 })
);
// Setup the model data -- an object describing the timeline bar node
// and an object for each event node:
var data = [
{ // this defines the actual time "Line" bar
key: "timeline", category: "Line",
lineSpacing: 30, // distance between timeline and event nodes
length: 2500, // the width of the timeline
start: new Date("1 May 2010"),
end: new Date("31 Jul 2010")
}
];
// prepare the model by adding links to the Line
for (var i = 0; i < data.length; i++) {
var d = data[i];
if (d.category == "Event") d.parent = "timeline";
}
myDiagram.model = $(go.TreeModel, { nodeDataArray: data });
// Define GoEditor framework structures for node generation
// Event nodes
// event node properties
var eDataProps = [
{ name: "event", type: "string" },
{ name: "date", type: "date" },
{ name: "description", type: "description" }
];
// event node predicates, TODO?
var ePredicates = [
function (nodeData) {
if (nodeData.date instanceof Date && nodeData.date != "Invalid Date") return true;
else return new Date(nodeData.date) != "Invalid Date";
},
// this is sort of sneaky -- I'm using this as a way to ensure there is always a Timeline Node, not to check if an Event Node can be created
function (nodeData) {
var timeline = myDiagram.findNodeForKey("timeline");
if (!timeline) {
var timelineData = { // this defines the actual time "Line" bar
key: "timeline", category: "Line",
lineSpacing: 30, // distance between timeline and event nodes
length: 2500, // the width of the timeline
start: new Date("1 May 2010"),
end: new Date("31 Jul 2010")
};
myDiagram.model.addNodeData(timelineData);
}
return true;
}
];
// event node postCreateFunction
var ePostCreate = function (node) {
var timeline = myDiagram.findNodeForKey("timeline");
var nodeData = node.data;
// then, update that timeline's boundary dates if the event node is outside them
var ed = nodeData.date; var tls = timeline.data.start; var tle = timeline.data.end;
ed = new Date(ed);
myDiagram.startTransaction();
if (ed > tle) {
myDiagram.model.set(timeline.data, "end", ed);
}
else if (ed < tls) {
myDiagram.model.set(timeline.data, "start", ed);
}
// then, add set the event node's "parent" data property to the timeline
myDiagram.model.setParentKeyForNodeData(nodeData, "timeline");
myDiagram.commitTransaction();
};
// event node default data
var eDefaultData = { isPositionedBelow: false, event: "Event", date: "Invalid Date", description: "Default description" };
nodeGenerationMap.add("Event", { dataProps: eDataProps, predicates: ePredicates, postCreateFunction: ePostCreate, defaultNodeData: eDefaultData });
// Range nodes
// range node data props
var rDataProps = [
{ name: "event", type: "string" },
{ name: "start", type: "date" },
{ name: "end", type: "date" },
{ name: "description", type: "string" },
{ name: "color", type: "color" }
];
var rPredicates = [
function (nodeData) {
var sd = true; var ed = true;
if (nodeData.start instanceof Date && nodeData.start == "Invalid Date") sd = false;
else if (new Date(nodeData.start) == "Invalid Date") sd = false;
if (nodeData.end instanceof Date && nodeData.end == "Invalid Date") ed = false;
else if (new Date(nodeData.end) == "Invalid Date") ed = false;
return (sd && ed);
},
// this is sort of sneaky -- I'm using this as a way to ensure there is always a Timeline Node, not to check if an Event Node can be created
function (nodeData) {
var timeline = myDiagram.findNodeForKey("timeline");
if (!timeline) {
var timelineData = { // this defines the actual time "Line" bar
key: "timeline", category: "Line",
lineSpacing: 30, // distance between timeline and event nodes
length: 2500, // the width of the timeline
start: new Date("1 May 2010"),
end: new Date("31 Jul 2010")
};
myDiagram.model.addNodeData(timelineData);
}
return true;
}
];
// if range node start / end dates outside timeline bounds, ajust timeline bounds
var rPostCreate = function (node) {
var timeline = myDiagram.findNodeForKey("timeline");
var dates = [node.data.start, node.data.end];
myDiagram.startTransaction();
for (let i in dates) {
var d = dates[i];
var tls = timeline.data.start; var tle = timeline.data.end;
if (d > tle) {
myDiagram.model.set(timeline.data, "end", d);
}
else if (d < tls) {
myDiagram.model.set(timeline.data, "start", d);
}
}
myDiagram.commitTransaction();
};
var rDefaultData = { isPositionedBelow: false };
nodeGenerationMap.add("Range", { dataProps: rDataProps, predicates: rPredicates, postCreateFunction: rPostCreate, defaultNodeData: rDefaultData });
// define the categories node data can be imported for
categories = ["Event", "Range"];
/*12DY@9dkd)dk*/
} // end init
</script>
</head>
<body onload="init();">
<div>
<nav>
<span id="currentStorageSpan"></span>
<ul id="fileMenus">
<li>
<a href="#">File</a>
<ul>
<li><a href="#" onclick="handlePromise('New')">New <p class="shortcut">(Ctrl + D)</p></a></li>
<li><a href="#" onclick="handlePromise('Load')">Open... <p class="shortcut">(Ctrl + O)</p></a></li>
<li><a href="#" onclick="handlePromise('Save')">Save <p class="shortcut">(Ctrl + S)</p></a></li>
<li><a href="#" onclick="handlePromise('SaveAs')">Save As...</a></li>
<li><a href="#" onclick="handlePromise('Delete')">Remove... <p class="shortcut">(Ctrl + R)</p></a></li>
<li><a href="#">Import Data From >>></a>
<ul>
<li><a href="#" onclick="authorizeGoogleUserForDataImport()">Google Sheet</a></li>
<li><a href="#" onclick="document.getElementById('file-input').click()">Local CSV File</a>
</ul>
</li>
<li><a href="#" onclick="makeDiagramImage()">Export PNG</a></li>
<li><a href="#" onclick="updateCurrentStorageSpan()">Change Storage Service</a></li>
</ul>
</li>
</ul>
<p id="isAutoSavingP"><input type="checkbox" id="isAutoSavingCheckbox" unchecked /> <label for="isAutoSavingCheckbox">Autosave Enabled</label></p>
<p id="ge-header">GoTimeline v1.0</p>
<div id="ge-filename">(Unsaved file)</div>
</nav>
<input type="file" id="file-input" style="display: none;" />
<div id="container" style="display: flex;">
<div id="myDiagramDiv" style="background: #252526; width: 100%; height:800px; float: left;"></div>
<div id="ge-inspector-div" style="background: #212121; flex-grow: 1; height: 800px; float: right;">
<div id="myInspectorDiv" class="inspector">
</div>
</div>
</div>
<div id="ge-footer">
<span>Built with the <a href="https://gojs.net">GoJS Diagramming Library</a>, by <a href="https://nwoods.com"> Northwoods Software</a>.</span>
</div>
<p>
<p> This editor allows one to build interactive timelines. Right click anywhere in the diagram to add a new Event or Range node. Select an element to edit its properties. </p> <p> Data formatted properly in rows and columns in either <code>.csv</code> or Google Sheet format can be imported using the File menu. A <code>png</code> of the entire timeline may be exported via the File menu. </p> <p> Timeline data can be saved or loaded to or from various cloud storage services via the Open, Remove, Save, and Save As buttons in the File menu. </p>
</p>
<script>
// returns number of days in timeline
function timelineDays() {
var timeline = myDiagram.model.findNodeDataForKey("timeline");
var startDate = timeline.start;
var endDate = timeline.end;
function treatAsUTC(date) {
var result = new Date(date);
result.setMinutes(result.getMinutes() - result.getTimezoneOffset());
return result;
}
function daysBetween(startDate, endDate) {
var millisecondsPerDay = 24 * 60 * 60 * 1000;
return (treatAsUTC(endDate) - treatAsUTC(startDate)) / millisecondsPerDay;
}
return daysBetween(startDate, endDate);
}
// Useful little functions
// convert "n" num of document units into days
function convertDocumentUnitsToDays (n) {
var timeline = myDiagram.findNodeForKey("timeline");
var daysInTimeline = Math.round((timeline.data.end-timeline.data.start)/(1000*60*60*24));
var daysPerPixel = daysInTimeline / timeline.data.length;
return n*daysPerPixel;
}
function getDaysBetween(first, second) {
// Take the difference between the dates and divide by milliseconds per day.
// Round to nearest whole number to deal with DST.
return Math.round((second-first)/(1000*60*60*24));
}
// returns the date represented by a point "n" pixels along the timeline (from the left)
function getDateForDist(n) {
var timeline = myDiagram.model.findNodeDataForKey("timeline");
var startDate = timeline.start;
var startDateMs = startDate.getTime() + startDate.getTimezoneOffset() * 60000;
var daysInTimeline = Math.round((timeline.end-timeline.start)/(1000*60*60*24));
var daysPerPixel = daysInTimeline / myDiagram.findNodeForKey("timeline").findObject("MAIN").actualBounds.width;
var msPerDay = 24 * 60 * 60 * 1000;
var date = new Date(startDateMs + n * msPerDay * daysPerPixel);
return date.toLocaleDateString();
}
// return a Date with the correct timezone offset. accepts a formatted string
function makeLocalDate(dateStr) {
d = new Date(dateStr);
d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
return d;
}
function refreshDraggableWindows () {
jQuery(".ge-menu").draggable({ handle: ".ge-handle", stack: ".ge-menu", containment: 'window', scroll: false });
}
// This custom Layout locates the timeline bar at (0,0)
// and alternates the event Nodes above and below the bar at
// the X-coordinate locations determined by their data.date values.
function TimelineLayout() {
go.Layout.call(this);
};
go.Diagram.inherit(TimelineLayout, go.Layout);
TimelineLayout.prototype.doLayout = function(coll) {
var diagram = this.diagram;
if (diagram === null) return;
coll = this.collectParts(coll);
diagram.startTransaction("TimelineLayout");
var line = null;
var parts = [];
var it = coll.iterator;
while (it.next()) {
var part = it.value;
if (part instanceof go.Link) continue;
if (part.category === "Line") { line = part; continue; }
if (part.data == null || part.category === "Title") continue;
parts.push(part);
if (part.category === "Range") continue;
var x = part.data.date;
if (x === undefined) { x = new Date(); part.data.date = x; }
}
if (!line) throw Error("No node of category 'Line' for TimelineLayout");
line.location = new go.Point(0, 0);
// sort parts so we lay them out based on their vertical distance to the timeline
parts.sort(function(a,b){
var distA = Math.abs(a.location.y-line.location.y);
var distB = Math.abs(b.location.y-line.location.y);
return distA > distB;
});
// lay out the events and event ranges above or below the timeline
if (parts.length > 0) {
// determine the offset from the main shape to the timeline's boundaries
var main = line.findMainElement();
var sw = main.strokeWidth;
var mainOffX = main.actualBounds.x;
var mainOffY = main.actualBounds.y;
// spacing is between the Line and the closest Nodes, defaults to 30
var spacing = line.data.lineSpacing;
if (!spacing) spacing = 30;
for (var i = 0; i < parts.length; i++) {
var part = parts[i];
var bnds = part.actualBounds;
var pt = null;
if (part.category == "Event") {
var dt = part.data.date;
var val = dateToValue(dt);
pt = line.graduatedPointForValue(val);
} else if (part.category == "Range") {
var sd = part.data.start; var ed = part.data.end;
sd = new Date(sd); ed = new Date(ed);
// give the proper "rangeWidth" px value (for space between start / end dates)
var val1 = dateToValue(sd); var val2 = dateToValue(ed); // val1, val2, in days
var daysPerDocUnit = getDaysPerDocumentUnit();
val1 /= daysPerDocUnit; val2 /= daysPerDocUnit; // val1, val2, in doc units
var w = val2 - val1;
diagram.model.set(part.data, "rangeWidth", w);
var midX = val1 + (.5*w);
pt = new go.Point(midX,0);
}
var tempLoc = new go.Point(pt.x, pt.y - bnds.height / 2 - spacing);
if (part.data.isPositionedBelow) {
var additionalOffset = (part.category == "Event") ? 50 : 0;
tempLoc = new go.Point(pt.x, pt.y + bnds.height / 2 + spacing + additionalOffset);
}
// check if this node will overlap with previously placed events, and offset if needed -- Event Nodes only
for (var j = 0; j < i; j++) {
if (part.data.category == "Range") continue;
if (part.data.key == parts[j].data.key) continue;
var partRect = null;
var bnds = part.actualBounds;
partRect = new go.Rect(tempLoc.x-(.5*bnds.width), tempLoc.y-(.5*bnds.height), bnds.width, bnds.height);
var otherLoc = parts[j].location;
var otherBnds = parts[j].actualBounds;
var otherRect = null;
// range nodes have a different locationSpot than event nodes, so we need to account for that when checking overlap
if (parts[j].category != "Range") {
otherRect = new go.Rect(otherLoc.x-(.5*otherBnds.width), otherLoc.y-(.5*otherBnds.height), otherBnds.width, otherBnds.height);
} else {
otherRect = new go.Rect(parts[j].actualBounds.x, parts[j].actualBounds.y, otherBnds.width, otherBnds.height);
}
if (partRect.intersectsRect(otherRect) && part.category == "Event") {
if (part.data.isPositionedBelow) {
tempLoc.offset(0, 1);
} else {
tempLoc.offset(0, -1);
}
j = -1; // now that we have a new location, we need to recheck in case we overlap with an event we didn't overlap before
}
}
// final padding
if (part.data.isPositionedBelow) {
tempLoc.offset(0, 10);
} else {
tempLoc.offset(0, -10);
}
part.location = tempLoc;
// re-position range nodes to factor out their "range rectangles" s.t. these ranges touch the timeline
if (part.category == "Range") {
var lineShape = line.findObject("MAIN");
var yDistFromLine = Math.abs(part.location.y-lineShape.actualBounds.y);
var rh = part.data.rangeHeight != undefined ? part.data.rangeHeight : yDistFromLine;
diagram.model.set(part.data, "rangeHeight", rh);
part.location = part.data.isPositionedBelow ? new go.Point(part.location.x, rh) : new go.Point(part.location.x, -rh);
}
}
}
if (diagram.selection.first()) {
var n = diagram.selection.first();
if (n.category == "Event" && !(n.actualBounds.intersectsRect(myDiagram.viewportBounds))) {
diagram.centerRect(new go.Rect(n.location.x, n.location.y, 1, 1));
}
}
diagram.commitTransaction("TimelineLayout");
};
// end TimelineLayout class
// This custom Link class was adapted from several of the samples
function BarLink() {
go.Link.call(this);
}
go.Diagram.inherit(BarLink, go.Link);
BarLink.prototype.getLinkPoint = function(node, port, spot, from, ortho, othernode, otherport) {
var r = new go.Rect(port.getDocumentPoint(go.Spot.TopLeft),
port.getDocumentPoint(go.Spot.BottomRight));
var op = otherport.getDocumentPoint(go.Spot.Center);
var main = node.category === "Line" ? node.findMainElement(): othernode.findMainElement();
var mainOffY = main.actualBounds.y;
var y = r.top;
if (node.category === "Line") {
y += mainOffY;
if (op.x < r.left) return new go.Point(r.left, y);
if (op.x > r.right) return new go.Point(r.right, y);
return new go.Point(op.x, y);
} else {
return new go.Point(r.centerX, r.bottom);
}
};
BarLink.prototype.getLinkDirection = function(node, port, linkpoint, spot, from, ortho, othernode, otherport) {
return 270;
};
// end BarLink class
// "value" is an interval value for the graduated panel that represents the timeline
function valueToDate(n) {
var timeline = myDiagram.model.findNodeDataForKey("timeline");
var startDate = timeline.start;
var startDateMs = startDate.getTime() + startDate.getTimezoneOffset() * 60000;
var msPerDay = 24 * 60 * 60 * 1000;
var date = new Date(startDateMs + n * msPerDay);
return date.toLocaleDateString();
}
// "value" is an interval value for the graduated panel that represents the timeline
function dateToValue(d) {
d = new Date(d);
var timeline = myDiagram.model.findNodeDataForKey("timeline");
var startDate = timeline.start;
var startDateMs = startDate.getTime() + startDate.getTimezoneOffset() * 60000;
var dateInMs = d.getTime() + d.getTimezoneOffset() * 60000;
var msSinceStart = dateInMs - startDateMs;
var msPerDay = 24 * 60 * 60 * 1000;
return msSinceStart / msPerDay;
}
// how many days are in 1 document unit?
function getDaysPerDocumentUnit() {
var line = myDiagram.findNodeForKey("timeline");
var daysInTimeline = Math.round((line.data.end-line.data.start)/(1000*60*60*24));
var daysPerPixel = daysInTimeline / myDiagram.findNodeForKey("timeline").findObject("MAIN").actualBounds.width;
return daysPerPixel;
}
/* Post-Init files go here ijiwd8up@90n */
</script>
</div>
</body>
</html>