// From http://www.redblobgames.com/x/1637-arrow-outside-the-box/ and http://simblob.blogspot.com/2016/10/outside-box.html // Copyright 2016 Red Blob Games // License: Apache v2.0 /* NOTE This code is a quick & dirty prototype of the idea, for a blog post. As of Oct 2016 I haven't tried to figure out the best way to make this reusable and modular. */ var shuffle = [22, 28, 26, 5, 7, 10, 16, 27, 9, 13, 18, 19, 17, 11, 23, 21, 15, 6, 20, 12, 25, 3, 29, 4, 14, 1, 24, 2, 0, 8]; var numCircles = shuffle.length; /** Average of two numbers */ function avg(a, b) { return (a + b) / 2; } /** Move 'end' to be at least minDistance away from 'start', in the same direction */ function projectMinDistance(start, end, minDistance) { var dist = Math.abs(end - start); var sign = end - start >= 0? 1 : -1; return start + Math.max(dist, minDistance) * sign; } function makeLotsOfCircles(svg) { for (var i = 0; i < numCircles; i++) { var row = Math.floor(shuffle[i] / 10), col = shuffle[i] % 10 + (row % 2) * 0.5; var g = svg.append('g') .attr('transform', 'translate(' + [60 + col * 36, 18 + row * 32] + ')'); var color = d3.hsl(i/30*360, 0.3, 0.7); g.append('circle') .attr('id', "c-" + i) .attr('fill', color) .attr('stroke', "black") .attr('stroke-width', 1.5) .attr('stroke-opacity', 0.2) .attr('r', 15); g.append('text') .attr('dy', 5) .text(i); } } /** returns the midpoint of one of the four edges */ function getSideOfRect(rect, side) { switch (side) { case 'left': return {x: rect.left, y: avg(rect.top, rect.bottom)}; case 'right': return {x: rect.right, y: avg(rect.top, rect.bottom)}; case 'top': return {x: avg(rect.left, rect.right), y: rect.top}; case 'bottom': return {x: avg(rect.left, rect.right), y: rect.bottom}; } throw "Invalid side " + side; } function removeArrow(id, svg) { svg.select('#'+id) .interrupt() .transition() .attr('opacity', 0.3) .delay(500) .duration(500) .attr('opacity', 0.0) .remove(); } function drawArrow(id, svg, source, sourceSide, target, targetSide) { var parentRect = svg.node().getBoundingClientRect(); var sourceRect = source.node().getBoundingClientRect(); var targetRect = target.node().getBoundingClientRect(); /* By default browsers other than IE will clip to the svg bounding * box. For years I would set overflow: hidden just for IE. But I * wondered, what happened if I did the opposite in other * browsers? To my surprise, it worked! */ var sourcePoint = getSideOfRect(sourceRect, sourceSide); var targetPoint = getSideOfRect(targetRect, targetSide); var minYDistance = Math.max(100, 0.15*Math.abs(sourcePoint.x, targetPoint.x)); var arrow = svg.selectAll('#'+id).data([1]); arrow .enter() .append('path') .attr('id', id) .attr('class', 'arrow') .attr('opacity', 0.0) .merge(arrow) .attr('transform', "translate(" + [-parentRect.left, -parentRect.top] + ")") .attr('d', ['M', sourcePoint.x, sourcePoint.y, 'C', // NOTE: this geometry assumes arrows are // primarily vertical; I've sketched out a better // set of control points but am using this quick & // dirty approach for this demo sourcePoint.x, projectMinDistance(sourcePoint.y, avg(sourcePoint.y, targetPoint.y), minYDistance), targetPoint.x, projectMinDistance(targetPoint.y, avg(targetPoint.y, sourcePoint.y), minYDistance), targetPoint.x, targetPoint.y ].join(" ")) .interrupt() .transition() .duration(100) .attr('opacity', 0.7); } function makeLotsOfArrows() { var p = d3.select('#more-arrows'); for (var i = 1; i < numCircles; i += 2) { (function (k) { var id = 'arrow-'+i; var anchor = p.append('cite') .style('margin-right', '1ex') .text(i) .on('mouseover touchstart', function() { drawArrow(id, d3.select('#diagram'), anchor, 'top', d3.select('#c-'+k), 'bottom'); d3.event.preventDefault(); }) .on('mouseout touchend', function() { removeArrow(id, d3.select('#diagram')); d3.event.preventDefault(); }); })(i); } } makeLotsOfCircles(d3.select('#diagram')); makeLotsOfArrows(); function addArrowFromAnchor(citeSelector, anchorSelector, anchorSide, targets) { function makeId(a, b) { return (a+'-'+b).replace(/[^-\w]/g, ''); } d3.select(citeSelector) .on('mouseover touchstart', function() { targets.forEach(function(target) { var targetSelector = target[0], targetSide = target[1]; var id = makeId(targetSelector, citeSelector); drawArrow(id, d3.select('#diagram'), d3.select(anchorSelector), anchorSide, d3.select(targetSelector), targetSide); d3.select(targetSelector).classed('highlighted', true); }); }) .on('mouseout touchend', function() { targets.forEach(function(target) { var targetSelector = target[0]; var id = makeId(targetSelector, citeSelector); removeArrow(id, d3.select('#diagram')); d3.select(targetSelector).classed('highlighted', false); }); }); } addArrowFromAnchor('#interact-arrow', '#anchor-1', 'bottom', [['#c-24', 'top']]); addArrowFromAnchor('#interact-show-svg', '#anchor-2', 'top', [['#diagram', 'bottom']]); addArrowFromAnchor('#interact-show-anchor', '#anchor-3', 'top', [['#anchor-1', 'bottom'], ['#anchor-2', 'right']]);