diff options
| author | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-09-04 09:05:24 +0200 |
| commit | 4f49b16079b27d7ce633619e4cddfdf2bffbc952 (patch) | |
| tree | c296c1e7f3c3e2f6859c6d3a1b1b208779b89679 /src/graph.js | |
| download | relic-master.tar.gz | |
Diffstat (limited to 'src/graph.js')
| -rw-r--r-- | src/graph.js | 824 |
1 files changed, 824 insertions, 0 deletions
diff --git a/src/graph.js b/src/graph.js new file mode 100644 index 0000000..5dec155 --- /dev/null +++ b/src/graph.js @@ -0,0 +1,824 @@ +/* This module contains javascript needed to render timeline graphs in Fossil. +** +** There can be multiple graphs on a single webpage, but this script is only +** loaded once. +** +** Prior to sourcing this script, there should be a separate +** <script type='application/json' id='timeline-data-NN'> for each graph, +** each containing JSON like this: +** +** { "iTableId": INTEGER, // Table sequence number (NN) +** "circleNodes": BOOLEAN, // True for circle nodes. False for squares +** "showArrowheads": BOOLEAN, // True for arrowheads. False to omit +** "iRailPitch": INTEGER, // Spacing between vertical lines (px) +** "nomo": BOOLEAN, // True to join merge lines with rails +** "iTopRow": INTEGER, // Index of top-most row in the graph +** "omitDescenders": BOOLEAN, // Omit ancestor lines off bottom of screen +** "fileDiff": BOOLEAN, // True for file diff. False for check-in +** "scrollToSelect": BOOLEAN, // Scroll to selection on first render +** "nrail": INTEGER, // Number of vertical "rails" +** "baseUrl": TEXT, // Top-level URL +** "dwellTimeout": INTEGER, // Tooltip show delay in milliseconds +** "closeTimeout": INTEGER, // Tooltip close delay in milliseconds +** "hashDigits": INTEGER, // Limit of tooltip hashes ("hash-digits") +** "rowinfo": ROWINFO-ARRAY } +** +** The rowinfo field is an array of structures, one per entry in the timeline, +** where each structure has the following fields: +** +** id: The id of the <div> element for the row. This is an integer. +** to get an actual id, prepend "m" to the integer. The top node +** is iTopRow and numbers increase moving down the timeline. +** bg: The background color for this row +** r: The "rail" that the node for this row sits on. The left-most +** rail is 0 and the number increases to the right. +** d: If exists and true then there is a "descender" - an arrow +** coming from the bottom of the page straight up to this node. +** mo: "merge-out". If it exists, this is the rail position +** for the upward portion of a merge arrow. The merge arrow goes as +** a solid normal merge line up to the row identified by "mu" and +** then as a dashed cherrypick merge line up further to "cu". +** If this value is omitted if there are no merge children. +** mu: The id of the row which is the top of the merge-out arrow. +** Only exists if "mo" exists. +** cu: Extend the mu merge arrow up to this row as a cherrypick +** merge line, if this value exists. +** u: Draw a thick child-line out of the top of this node and up to +** the node with an id equal to this value. 0 if it is straight to +** the top of the page, -1 if there is no thick-line riser. +** f: 0x01: a leaf node. +** au: An array of integers that define thick-line risers for branches. +** The integers are in pairs. For each pair, the first integer is +** is the rail on which the riser should run and the second integer +** is the id of the node upto which the riser should run. If there +** are no risers, this array does not exist. +** mi: "merge-in". An array of integer rail positions from which +** merge arrows should be drawn into this node. If the value is +** negative, then the rail position is -1-mi[] and a thin merge-arrow +** descender is drawn to the bottom of the screen. This array is +** omitted if there are no inbound merges. +** ci: "cherrypick-in". Like "mi" except for cherrypick merges. +** omitted if there are no cherrypick merges. +** h: The artifact hash of the object being graphed +*/ +/* The amendCss() function does a one-time change to the CSS to account +** for the "circleNodes" and "showArrowheads" settings. Do this change +** only once, even if there are multiple graphs being rendered. +*/ +var amendCssOnce = 1; // Only change the CSS one time +function amendCss(circleNodes,showArrowheads){ + if( !amendCssOnce ) return; + var css = ""; + if( circleNodes ){ + css += ".tl-node, .tl-node:after { border-radius: 50%; }"; + } + if( !showArrowheads ){ + css += ".tl-arrow.u { display: none; }"; + } + if( css!=="" ){ + var style = document.createElement("style"); + style.textContent = css; + document.querySelector("head").appendChild(style); + } + amendCssOnce = 0; +} + +/* The <span> object that holds the tooltip */ +var tooltipObj = document.createElement("span"); +tooltipObj.className = "tl-tooltip"; +tooltipObj.style.display = "none"; +document.getElementsByClassName("content")[0].appendChild(tooltipObj); +tooltipObj.onmouseenter = function(){ + /* Hold the tooltip constant as long as the mouse is over the tooltip. + ** In other words, do not let any of the timers changes the tooltip while + ** the mouse is directly over the tooltip. This makes it easier for the + ** user to move over top of the "copy-button" or the hyperlink to the + ** /info page. */ + stopCloseTimer(); + stopDwellTimer(); + tooltipInfo.ixHover = tooltipInfo.ixActive; +} +tooltipObj.onmouseleave = function(){ + if (tooltipInfo.ixActive != -1) resumeCloseTimer(); +}; + +/* State information for the tooltip popup and its timers */ +window.tooltipInfo = { + dwellTimeout: 250, /* The tooltip dwell timeout. */ + closeTimeout: 3000, /* The tooltip close timeout. */ + hashDigits: 16, /* Limit of tooltip hashes ("hash-digits"). */ + idTimer: 0, /* The tooltip dwell timer id */ + idTimerClose: 0, /* The tooltip close timer id */ + ixHover: -1, /* The mouse is over a thick riser arrow for + ** tx.rowinfo[ixHover]. Or -2 when the mouse is + ** over a graph node. Or -1 when the mouse is not + ** over anything. */ + ixActive: -1, /* The item shown in the tooltip is tx.rowinfo[ixActive]. + ** ixActive is -1 if the tooltip is not visible */ + nodeHover: null, /* Graph node under mouse when ixHover==-2 */ + idNodeActive: 0, /* Element ID of the graph node with the tooltip. */ + posX: 0, posY: 0 /* The last mouse position. */ +}; + +/* Functions used to control the tooltip popup and its timer */ +function onKeyDown(event){ /* Hide the tooltip when ESC key pressed */ + var key = event.which || event.keyCode; + if( key==27 ){ + event.stopPropagation(); + hideGraphTooltip(); + } +} +function hideGraphTooltip(){ /* Hide the tooltip */ + document.removeEventListener('keydown',onKeyDown,/* useCapture == */true); + stopCloseTimer(); + tooltipObj.style.display = "none"; + tooltipInfo.ixActive = -1; + tooltipInfo.idNodeActive = 0; +} +window.onpagehide = hideGraphTooltip; +function stopDwellTimer(){ + if(tooltipInfo.idTimer!=0){ + clearTimeout(tooltipInfo.idTimer); + tooltipInfo.idTimer = 0; + } +} +function resumeCloseTimer(){ + /* This timer must be stopped explicitly to reset the elapsed timeout. */ + if(tooltipInfo.idTimerClose==0 && tooltipInfo.closeTimeout>0) { + tooltipInfo.idTimerClose = setTimeout(function(){ + tooltipInfo.idTimerClose = 0; + hideGraphTooltip(); + },tooltipInfo.closeTimeout); + } +} +function stopCloseTimer(){ + if(tooltipInfo.idTimerClose!=0){ + clearTimeout(tooltipInfo.idTimerClose); + tooltipInfo.idTimerClose = 0; + } +} + +/* Construct that graph corresponding to the timeline-data-N object that +** is passed in by the tx parameter */ +function TimelineGraph(tx){ + var topObj = document.getElementById("timelineTable"+tx.iTableId); + amendCss(tx.circleNodes, tx.showArrowheads); + tooltipInfo.dwellTimeout = tx.dwellTimeout + tooltipInfo.closeTimeout = tx.closeTimeout + tooltipInfo.hashDigits = tx.hashDigits + topObj.onclick = clickOnGraph + topObj.ondblclick = dblclickOnGraph + topObj.onmousemove = function(e) { + var ix = findTxIndex(e); + topObj.style.cursor = (ix<0) ? "" : "pointer" + mouseOverGraph(e,ix,null); + }; + topObj.onmouseleave = function(e) { + /* Hide the tooltip if the mouse is outside the "timelineTableN" element, + ** and outside the tooltip. */ + if(e.relatedTarget && e.relatedTarget != tooltipObj){ + tooltipInfo.ixHover = -1; + hideGraphTooltip(); + stopDwellTimer(); + stopCloseTimer(); + } + }; + function mouseOverNode(e){ /* Invoked by mousemove events over a graph node */ + e.stopPropagation() + mouseOverGraph(e,-2,this) + } + /* Combined mousemove handler for graph nodes and rails. */ + function mouseOverGraph(e,ix,node){ + stopDwellTimer(); // Mouse movement: reset the dwell timer. + var ownTooltip = // Check if the hovered element already has the tooltip. + (ix>=0 && ix==tooltipInfo.ixActive) || + (ix==-2 && tooltipInfo.idNodeActive==node.id); + if(ownTooltip) stopCloseTimer(); // ownTooltip: clear the close timer. + else resumeCloseTimer(); // !ownTooltip: resume the close timer. + tooltipInfo.ixHover = ix; + tooltipInfo.nodeHover = node; + tooltipInfo.posX = e.clientX; + tooltipInfo.posY = e.clientY; + if(ix!=-1 && !ownTooltip && tooltipInfo.dwellTimeout>0){ // Go dwell timer. + tooltipInfo.idTimer = setTimeout(function(){ + tooltipInfo.idTimer = 0; + stopCloseTimer(); + showGraphTooltip(); + },tooltipInfo.dwellTimeout); + } + } + var canvasDiv; + var railPitch; + var mergeOffset; + var node, arrow, arrowSmall, line, mArrow, mLine, wArrow, wLine; + + function initGraph(){ + var parent = topObj.rows[0].cells[1]; + parent.style.verticalAlign = "top"; + canvasDiv = document.createElement("div"); + canvasDiv.className = "tl-canvas"; + canvasDiv.style.position = "absolute"; + parent.appendChild(canvasDiv); + + var elems = {}; + var elemClasses = [ + "rail", "mergeoffset", "node", "arrow u", "arrow u sm", "line", + "arrow merge r", "line merge", "arrow warp", "line warp", + "line cherrypick", "line dotted" + ]; + for( var i=0; i<elemClasses.length; i++ ){ + var cls = elemClasses[i]; + var elem = document.createElement("div"); + elem.className = "tl-" + cls; + if( cls.indexOf("line")==0 ) elem.className += " v"; + canvasDiv.appendChild(elem); + var k = cls.replace(/\s/g, "_"); + var r = elem.getBoundingClientRect(); + var w = Math.round(r.right - r.left); + var h = Math.round(r.bottom - r.top); + elems[k] = {w: w, h: h, cls: cls}; + } + node = elems.node; + arrow = elems.arrow_u; + arrowSmall = elems.arrow_u_sm; + line = elems.line; + mArrow = elems.arrow_merge_r; + mLine = elems.line_merge; + cpLine = elems.line_cherrypick; + wArrow = elems.arrow_warp; + wLine = elems.line_warp; + dotLine = elems.line_dotted; + + var minRailPitch = Math.ceil((node.w+line.w)/2 + mArrow.w + 1); + if( window.innerWidth<400 ){ + railPitch = minRailPitch; + }else{ + if( tx.iRailPitch>0 ){ + railPitch = tx.iRailPitch; + }else{ + railPitch = elems.rail.w; + railPitch -= Math.floor((tx.nrail-1)*(railPitch-minRailPitch)/21); + } + railPitch = Math.max(railPitch, minRailPitch); + } + + if( tx.nomo ){ + mergeOffset = 0; + }else{ + mergeOffset = railPitch-minRailPitch-mLine.w; + mergeOffset = Math.min(mergeOffset, elems.mergeoffset.w); + mergeOffset = mergeOffset>0 ? mergeOffset + line.w/2 : 0; + } + + var canvasWidth = (tx.nrail-1)*railPitch + node.w; + canvasDiv.style.width = canvasWidth + "px"; + canvasDiv.style.position = "relative"; + } + function drawBox(cls,color,x0,y0,x1,y1){ + var n = document.createElement("div"); + x0 = Math.floor(x0); + y0 = Math.floor(y0); + x1 = x1 || x1===0 ? Math.floor(x1) : x0; + y1 = y1 || y1===0 ? Math.floor(y1) : y0; + if( x0>x1 ){ var t=x0; x0=x1; x1=t; } + if( y0>y1 ){ var t=y0; y0=y1; y1=t; } + var w = x1-x0; + var h = y1-y0; + n.style.position = "absolute"; + n.style.left = x0+"px"; + n.style.top = y0+"px"; + if( w ) n.style.width = w+"px"; + if( h ) n.style.height = h+"px"; + if( color ) n.style.backgroundColor = color; + n.className = "tl-"+cls; + canvasDiv.appendChild(n); + return n; + } + function absoluteY(obj){ + var y = 0; + do{ + y += obj.offsetTop; + }while( obj = obj.offsetParent ); + return y; + } + function absoluteX(obj){ + var x = 0; + do{ + x += obj.offsetLeft; + }while( obj = obj.offsetParent ); + return x; + } + function miLineY(p){ + return p.y + node.h - mLine.w - 1; + } + function drawLine(elem,color,x0,y0,x1,y1){ + var cls = elem.cls + " "; + if( x1===null ){ + x1 = x0+elem.w; + cls += "v"; + }else{ + y1 = y0+elem.w; + cls += "h"; + } + return drawBox(cls,color,x0,y0,x1,y1); + } + function drawUpArrow(from,to,color,id){ + var y = to.y + node.h; + var arrowSpace = from.y - y + (!from.id || from.r!=to.r ? node.h/2 : 0); + var arw = arrowSpace < arrow.h*1.5 ? arrowSmall : arrow; + var x = to.x + (node.w-line.w)/2; + var y0 = from.y + node.h/2; + var y1 = Math.ceil(to.y + node.h + arw.h/2); + var n = drawLine(line,color,x,y0,null,y1); + addToolTip(n,id) + x = to.x + (node.w-arw.w)/2; + n = drawBox(arw.cls,null,x,y); + if(color) n.style.borderBottomColor = color; + addToolTip(n,id) + } + function drawDotted(from,to,color,id){ + var x = to.x + (node.w-line.w)/2; + var y0 = from.y + node.h/2; + var y1 = Math.ceil(to.y + node.h); + var n = drawLine(dotLine,null,x,y0,null,y1) + if( color ) n.style.borderColor = color + addToolTip(n,id) + } + function addToolTip(n,id){ + if( id ) n.setAttribute("data-ix",id-tx.iTopRow) + } + /* Draw thin horizontal or vertical lines representing merges */ + function drawMergeLine(x0,y0,x1,y1){ + drawLine(mLine,null,x0,y0,x1,y1); + } + function drawCherrypickLine(x0,y0,x1,y1){ + drawLine(cpLine,null,x0,y0,x1,y1); + } + /* Draw an arrow representing an in-bound merge from the "rail"-th rail + ** over to the node of "p". Make it a checkpoint merge is "isCP" is true */ + function drawMergeArrow(p,rail,isCP){ + var x0 = rail*railPitch + node.w/2; + if( rail in mergeLines ){ + x0 += mergeLines[rail]; + if( p.r<rail ) x0 += mLine.w; + }else{ + x0 += (p.r<rail ? -1 : 1)*line.w/2; + } + var x1 = mArrow.w ? mArrow.w/2 : -node.w/2; + x1 = p.x + (p.r<rail ? node.w + Math.ceil(x1) : -x1); + var y = miLineY(p); + var x = p.x + (p.r<rail ? node.w : -mArrow.w); + var cls; + if( isCP ){ + drawCherrypickLine(x0,y,x1,null); + cls = "arrow cherrypick " + (p.r<rail ? "l" : "r"); + }else{ + drawMergeLine(x0,y,x1,null); + cls = "arrow merge " + (p.r<rail ? "l" : "r"); + } + drawBox(cls,null,x,y+(mLine.w-mArrow.h)/2); + } + function drawNode(p, btm){ + if( p.bg ){ + var e = document.getElementById("mc"+p.id); + if(e) e.style.backgroundColor = p.bg; + e = document.getElementById("md"+p.id); + if(e) e.style.backgroundColor = p.bg; + } + if( p.r<0 ) return; + if( p.u>0 ) drawUpArrow(p,tx.rowinfo[p.u-tx.iTopRow],p.fg,p.id); + if( p.sb>0 ) drawDotted(p,tx.rowinfo[p.sb-tx.iTopRow],p.fg,p.id); + var cls = node.cls; + if( p.hasOwnProperty('mi') && p.mi.length ) cls += " merge"; + if( p.f&2 ) cls += " closed-leaf"; + else if( p.f&1 ) cls += " leaf"; + var n = drawBox(cls,p.bg,p.x,p.y); + n.id = "tln"+p.id; + n.onclick = clickOnNode; + n.ondblclick = dblclickOnNode; + n.onmousemove = mouseOverNode; + n.style.zIndex = 10; + if( p.f&2 ){ + var pt1 = 0; + var pt2 = 100; + if( tx.circleNodes ){ + pt1 = 14; + pt2 = 86; + } + n.innerHTML = "<svg width='100%' height='100%'viewbox='0 0 100 100'>" + + `<path d='M ${pt1},${pt1} L ${pt2},${pt2} M ${pt1},${pt2} L ${pt2},${pt1}'` + + " stroke='currentcolor' stroke-width='13'/>" + + "</svg>"; + } + if( !tx.omitDescenders ){ + if( p.u==0 ){ + if( p.hasOwnProperty('mo') && p.r==p.mo ){ + var ix = p.hasOwnProperty('cu') ? p.cu : p.mu; + var top = tx.rowinfo[ix-tx.iTopRow] + drawUpArrow(p,{x: p.x, y: top.y-node.h}, p.fg, p.id); + }else if( p.y>100 ){ + drawUpArrow(p,{x: p.x, y: p.y-50}, p.fg, p.id); + }else{ + drawUpArrow(p,{x: p.x, y: 0},p.fg, p.id); + } + } + if( p.hasOwnProperty('d') ){ + if( p.y + 150 >= btm ){ + drawUpArrow({x: p.x, y: btm - node.h/2},p,p.fg,p.id); + }else{ + drawUpArrow({x: p.x, y: p.y+50},p,p.fg,p.id); + drawDotted({x: p.x, y: p.y+63},{x: p.x, y: p.y+50-node.h/2},p.fg,p.id); + } + } + } + if( p.hasOwnProperty('mo') ){ + var x0 = p.x + node.w/2; + var x1 = p.mo*railPitch + node.w/2; + var u = tx.rowinfo[p.mu-tx.iTopRow]; + var mtop = u; + if( p.hasOwnProperty('cu') ){ + mtop = tx.rowinfo[p.cu-tx.iTopRow]; + } + var y1 = miLineY(u); + if( p.u<=0 || p.mo!=p.r ){ + if( p.u==0 && p.mo==p.r ){ + mergeLines[p.mo] = mtop.r<p.r ? -mergeOffset-mLine.w : mergeOffset; + }else{ + mergeLines[p.mo] = -mLine.w/2; + } + x1 += mergeLines[p.mo] + var y0 = p.y+2; + var isCP = p.hasOwnProperty('cu'); + if( p.mu==p.id ){ + /* Special case: The merge riser already exists. Only draw the + /* horizontal line or arrow going from the node out to the riser. */ + var dx = x1<x0 ? mArrow.w : -mArrow.w; + if( isCP ){ + drawCherrypickLine(x0,y0,x1+dx,null); + cls = "arrow cherrypick " + (x1<x0 ? "l" : "r"); + }else{ + drawMergeLine(x0,y0,x1+dx,null); + cls = "arrow merge " + (x1<x0 ? "l" : "r"); + } + if( !isCP || p.mu==p.cu ){ + dx = x1<x0 ? mLine.w : -(mArrow.w + mLine.w/2); + drawBox(cls,null,x1+dx,y0+(mLine.w-mArrow.h)/2); + } + y1 = y0; + }else{ + drawMergeLine(x0,y0,x1+(x0<x1 ? mLine.w : 0),null); + drawMergeLine(x1,y0+mLine.w,null,y1); + } + if( isCP && p.cu!=p.id ){ + var u2 = tx.rowinfo[p.cu-tx.iTopRow]; + var y2 = miLineY(u2); + drawCherrypickLine(x1,y1,null,y2); + } + }else if( mergeOffset ){ + mergeLines[p.mo] = mtop.r<p.r ? -mergeOffset-mLine.w : mergeOffset; + x1 += mergeLines[p.mo]; + if( p.mu<p.id ){ + drawMergeLine(x1,p.y+node.h/2,null,y1); + } + if( p.hasOwnProperty('cu') ){ + var u2 = tx.rowinfo[p.cu-tx.iTopRow]; + var y2 = miLineY(u2); + drawCherrypickLine(x1,y1,null,y2); + } + }else{ + delete mergeLines[p.mo]; + } + } + if( p.hasOwnProperty('au') ){ + for( var i=0; i<p.au.length; i+=2 ){ + var rail = p.au[i]; + var x0 = p.x + node.w/2; + var x1 = rail*railPitch + (node.w-line.w)/2; + if( x0<x1 ){ + x0 = Math.ceil(x0); + x1 += line.w; + } + var y0 = p.y + (node.h-line.w)/2; + var u = tx.rowinfo[p.au[i+1]-tx.iTopRow]; + if( u.id<p.id ){ + // normal thick up-arrow + drawLine(line,u.fg,x0,y0,x1,null); + drawUpArrow(p,u,u.fg,u.id); + }else{ + // timewarp: The child node occurs before the parent + var y1 = u.y + (node.h-line.w)/2; + var n = drawLine(wLine,u.fg,x0,y0,x1,null); + addToolTip(n,u.id) + n = drawLine(wLine,u.fg,x1-line.w,y0,null,y1+line.w); + addToolTip(n,u.id) + n = drawLine(wLine,u.fg,x1,y1,u.x-wArrow.w/2,null); + addToolTip(n,u.id) + var x = u.x-wArrow.w; + var y = u.y+(node.h-wArrow.h)/2; + n = drawBox(wArrow.cls,null,x,y); + addToolTip(n,u.id) + if( u.fg ) n.style.borderLeftColor = u.fg; + } + } + } + if( p.hasOwnProperty('mi') ){ + for( var i=0; i<p.mi.length; i++ ){ + var rail = p.mi[i]; + if( rail<0 ){ + rail = -1-rail; + mergeLines[rail] = -mLine.w/2; + var x = rail*railPitch + (node.w-mLine.w)/2; + var y = miLineY(p); + drawMergeLine(x,y,null,mergeBtm[rail]); + mergeBtm[rail] = y; + } + drawMergeArrow(p,rail,0); + } + } + if( p.hasOwnProperty('ci') ){ + for( var i=0; i<p.ci.length; i++ ){ + var rail = p.ci[i]; + if( rail<0 ){ + rail = -rail; + mergeLines[rail] = -mLine.w/2; + var x = rail*railPitch + (node.w-mLine.w)/2; + var y = miLineY(p); + drawCherrypickLine(x,y,null,mergeBtm[rail]); + mergeBtm[rail] = y; + } + drawMergeArrow(p,rail,1); + } + } + } + var mergeLines; + var mergeBtm = new Array; + function renderGraph(){ + mergeLines = {}; + canvasDiv.innerHTML = ""; + var canvasY = absoluteY(canvasDiv); + for(var i=0; i<tx.rowinfo.length; i++ ){ + var e = document.getElementById("m"+tx.rowinfo[i].id); + tx.rowinfo[i].y = absoluteY(e) - canvasY; + tx.rowinfo[i].x = tx.rowinfo[i].r*railPitch; + } + var tlBtm = document.getElementById(tx.bottomRowId); + if( tlBtm.offsetHeight<node.h ){ + tlBtm.style.height = node.h + "px"; + } + var btm = absoluteY(tlBtm) - canvasY + tlBtm.offsetHeight; + for( var i=0; i<tx.nrail; i++) mergeBtm[i] = btm; + for( var i=tx.rowinfo.length-1; i>=0; i-- ){ + drawNode(tx.rowinfo[i], btm); + } + } + var selRow; + function clickOnNode(e){ + hideGraphTooltip() + var p = tx.rowinfo[parseInt(this.id.match(/\d+$/)[0], 10)-tx.iTopRow]; + if( !selRow ){ + selRow = p; + this.className += " sel"; + canvasDiv.className += " sel"; + }else if( selRow==p ){ + selRow = null; + this.className = this.className.replace(" sel", ""); + canvasDiv.className = canvasDiv.className.replace(" sel", ""); + }else{ + if( tx.fileDiff ){ + location.href=tx.baseUrl + "/fdiff?v1="+selRow.h+"&v2="+p.h; + }else{ + var href = tx.baseUrl + "/vdiff?from="+selRow.h+"&to="+p.h; + let params = (new URL(document.location)).searchParams; + if(params && typeof params === "object"){ + /* When called from /timeline page, If chng=str was specified in the + ** QueryString, specify glob=str on the /vdiff page */ + let glob = params.get("chng"); + if( !glob ){ + /* When called from /vdiff page, keep the glob= QueryString if + ** present. */ + glob = params.get("glob"); + } + if( glob ){ + href += "&glob=" + glob; + } + } + location.href = href; + } + } + e.stopPropagation() + } + function dblclickOnNode(e){ + var p = tx.rowinfo[parseInt(this.id.match(/\d+$/)[0], 10)-tx.iTopRow]; + window.location.href = tx.baseUrl+"/info/"+p.h + e.stopPropagation() + } + function findTxIndex(e){ + if( !tx.rowinfo ) return -1; + /* Look at all the graph elements. If any graph elements that is near + ** the click-point "e" and has a "data-ix" attribute, then return + ** the value of that attribute. Otherwise return -1 */ + var x = e.clientX + window.pageXOffset - absoluteX(canvasDiv); + var y = e.clientY + window.pageYOffset - absoluteY(canvasDiv); + var aNode = canvasDiv.childNodes + var nNode = aNode.length; + var i; + for(i=0;i<nNode;i++){ + var n = aNode[i] + if( !n.hasAttribute("data-ix") ) continue; + if( x<n.offsetLeft-5 ) continue; + if( x>n.offsetLeft+n.offsetWidth+5 ) continue; + if( y<n.offsetTop-5 ) continue; + if( y>n.offsetTop+n.offsetHeight ) continue; + return n.getAttribute("data-ix") + } + return -1 + } + /* Compute the hyperlink for the branch graph for tx.rowinfo[ix] */ + function branchHyperlink(ix){ + var br = tx.rowinfo[ix].br + var dest = tx.baseUrl + "/timeline?r=" + encodeURIComponent(br) + dest += tx.fileDiff ? "&m&cf=" : "&m&c=" + dest += encodeURIComponent(tx.rowinfo[ix].h) + return dest + } + function clickOnGraph(e){ + stopCloseTimer(); + stopDwellTimer(); + tooltipInfo.ixHover = findTxIndex(e); + tooltipInfo.posX = e.clientX; + tooltipInfo.posY = e.clientY; + showGraphTooltip(); + } + function showGraphTooltip(){ + var html = null + var ix = -1 + if( tooltipInfo.ixHover==-2 ){ + ix = parseInt(tooltipInfo.nodeHover.id.match(/\d+$/)[0],10)-tx.iTopRow + var h = tx.rowinfo[ix].h + var dest = tx.baseUrl + "/info/" + h + h = h.slice(0,tooltipInfo.hashDigits); // Assume single-byte characters. + if( tx.fileDiff ){ + html = "artifact <a id=\"tooltip-link\" href=\""+dest+"\">"+h+"</a>" + }else{ + html = "check-in <a id=\"tooltip-link\" href=\""+dest+"\">"+h+"</a>" + } + tooltipInfo.ixActive = -2; + tooltipInfo.idNodeActive = tooltipInfo.nodeHover.id; + }else if( tooltipInfo.ixHover>=0 ){ + ix = tooltipInfo.ixHover + var br = tx.rowinfo[ix].br + var dest = branchHyperlink(ix) + var hbr = br.replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + html = "branch <a id=\"tooltip-link\" href=\""+dest+"\">"+hbr+"</a>" + tooltipInfo.ixActive = ix; + tooltipInfo.idNodeActive = 0; + } + if( html ){ + /* Setup while hidden, to ensure proper dimensions. */ + var s = getComputedStyle(document.body) + if( tx.rowinfo[ix].bg.length ){ + tooltipObj.style.backgroundColor = tx.rowinfo[ix].bg + }else{ + tooltipObj.style.backgroundColor = s.getPropertyValue('background-color') + } + tooltipObj.style.borderColor = + tooltipObj.style.color = s.getPropertyValue('color') + tooltipObj.style.visibility = "hidden" + tooltipObj.innerHTML = html + tooltipObj.insertBefore(makeCopyButton("tooltip-link",0,0), + tooltipObj.childNodes[1]); + tooltipObj.style.display = "inline" + tooltipObj.style.position = "absolute" + var x = tooltipInfo.posX + 4 + window.pageXOffset + - absoluteX(tooltipObj.offsetParent) + tooltipObj.style.left = x+"px" + var y = tooltipInfo.posY + window.pageYOffset + - tooltipObj.clientHeight - 4 + - absoluteY(tooltipObj.offsetParent) + tooltipObj.style.top = y+"px" + tooltipObj.style.visibility = "visible" + document.addEventListener('keydown',onKeyDown,/* useCapture == */true); + }else{ + hideGraphTooltip() + } + } + function dblclickOnGraph(e){ + var ix = findTxIndex(e); + hideGraphTooltip() + if( ix>=0 ){ + var dest = branchHyperlink(ix) + window.location.href = dest + } + } + function changeDisplay(selector,value){ + var x = document.getElementsByClassName(selector); + var n = x.length; + for(var i=0; i<n; i++) {x[i].style.display = value;} + } + function changeDisplayById(id,value){ + var x = document.getElementById(id); + if(x) x.style.display=value; + } + function toggleDetail(evt){ + /* Ignore clicks to hyperlinks and other "click-responsive" HTML elements. + ** This click-handler is set for <SPAN> elements with the CSS class names + ** "timelineEllipsis" and "timelineCompactComment", which are part of the + ** "Compact" and "Simple" views. */ + var xClickyHTML = /^(?:A|AREA|BUTTON|INPUT|LABEL|SELECT|TEXTAREA|DETAILS)$/; + if( xClickyHTML.test(evt.target.tagName) ) return; + var id = parseInt(this.getAttribute('data-id')) + var x = document.getElementById("detail-"+id); + if( x.style.display=="inline" ){ + x.style.display="none"; + document.getElementById("ellipsis-"+id).textContent = "..."; + changeDisplayById("links-"+id,"none"); + }else{ + x.style.display="inline"; + document.getElementById("ellipsis-"+id).textContent = "←"; + changeDisplayById("links-"+id,"inline"); + } + checkHeight(); + } + function scrollToSelected(){ + var x = document.getElementsByClassName('timelineSelected'); + if(x[0]){ + var h = window.innerHeight; + var y = absoluteY(x[0]) - h/2; + if( y>0 ) window.scrollTo(0, y); + } + } + if( tx.rowinfo ){ + var lastRow = + document.getElementById("m"+tx.rowinfo[tx.rowinfo.length-1].id); + var lastY = 0; + function checkHeight(){ + var h = absoluteY(lastRow); + if( h!=lastY ){ + renderGraph(); + lastY = h; + } + setTimeout(checkHeight, 1000); + } + initGraph(); + checkHeight(); + }else{ + function checkHeight(){} + } + if( tx.scrollToSelect ){ + scrollToSelected(); + } + + /* Set the onclick= attributes for elements of the "Compact" and + ** "Simple" views so that clicking turns the details on and off. + */ + var lx = topObj.getElementsByClassName('timelineEllipsis'); + var i; + for(i=0; i<lx.length; i++){ + if( lx[i].hasAttribute('data-id') ){ + lx[i].addEventListener('click',toggleDetail); + } + } + lx = topObj.getElementsByClassName('timelineCompactComment'); + for(i=0; i<lx.length; i++){ + if( lx[i].hasAttribute('data-id') ){ + lx[i].addEventListener('click',toggleDetail); + } + } + if( window.innerWidth<400 ){ + /* On narrow displays, shift the date from the first column to the + ** third column, to make the first column narrower */ + lx = topObj.getElementsByClassName('timelineDateRow'); + for(i=0; i<lx.length; i++){ + var rx = lx[i]; + if( rx.getAttribute('data-reordered') ) break; + rx.setAttribute('data-reordered',1); + rx.appendChild(rx.firstChild); + rx.insertBefore(rx.childNodes[1],rx.firstChild); + } + /* Do not show the HH:MM timestamps on very narrow displays + ** as they take up too much horizontal space. */ + lx = topObj.getElementsByClassName('timelineHistLink'); + for(i=0; i<lx.length; i++){ + var rx = lx[i]; + rx.style.display="none"; + } + } +} + +/* Look for all timeline-data-NN objects. Load each one and draw +** a graph for each one. +*/ +(function(){ + var i; + for(i=0; 1; i++){ + var dataObj = document.getElementById("timeline-data-"+i); + if(!dataObj) break; + var txJson = dataObj.textContent || dataObj.innerText; + var tx = JSON.parse(txJson); + TimelineGraph(tx); + } +}()); |
