modules/ui/curtain.js (203 lines of code) (raw):

import { easeLinear as d3_easeLinear } from 'd3-ease'; import { event as d3_event, select as d3_select } from 'd3-selection'; import { textDirection } from '../util/locale'; import { uiToggle } from './toggle'; // Tooltips and svg mask used to highlight certain features export function uiCurtain() { var surface = d3_select(null), tooltip = d3_select(null), darkness = d3_select(null); function curtain(selection) { surface = selection .append('svg') .attr('id', 'curtain') .style('z-index', 1000) .style('pointer-events', 'none') .style('position', 'absolute') .style('top', 0) .style('left', 0); darkness = surface.append('path') .attr('x', 0) .attr('y', 0) .attr('class', 'curtain-darkness'); d3_select(window).on('resize.curtain', resize); tooltip = selection.append('div') .attr('class', 'tooltip') .style('z-index', 1002); tooltip .append('div') .attr('class', 'tooltip-arrow'); tooltip .append('div') .attr('class', 'tooltip-inner'); resize(); function resize() { surface .attr('width', window.innerWidth) .attr('height', window.innerHeight); curtain.cut(darkness.datum()); } } /** * Reveal cuts the curtain to highlight the given box, * and shows a tooltip with instructions next to the box. * * @param {String|ClientRect} [box] box used to cut the curtain * @param {String} [text] text for a tooltip * @param {Object} [options] * @param {string} [options.tooltipClass] optional class to add to the tooltip * @param {integer} [options.duration] transition time in milliseconds * @param {string} [options.buttonText] if set, create a button with this text label * @param {function} [options.buttonCallback] if set, the callback for the button * @param {String|ClientRect} [options.tooltipBox] box for tooltip position, if different from box for the curtain */ curtain.reveal = function(box, text, options) { if (typeof box === 'string') { box = d3_select(box).node(); } if (box && box.getBoundingClientRect) { box = copyBox(box.getBoundingClientRect()); } options = options || {}; var tooltipBox; if (options.tooltipBox) { tooltipBox = options.tooltipBox; if (typeof tooltipBox === 'string') { tooltipBox = d3_select(tooltipBox).node(); } if (tooltipBox && tooltipBox.getBoundingClientRect) { tooltipBox = copyBox(tooltipBox.getBoundingClientRect()); } } else { tooltipBox = box; } if (tooltipBox && text) { // pseudo markdown bold text for the instruction section.. var parts = text.split('**'); var html = parts[0] ? '<span>' + parts[0] + '</span>' : ''; if (parts[1]) { html += '<span class="instruction">' + parts[1] + '</span>'; } html = html.replace(/\*(.*?)\*/g, '<em>$1</em>'); // emphasis html = html.replace(/\{br\}/g, '<br/><br/>'); // linebreak if (options.buttonText && options.buttonCallback) { html += '<div class="button-section">' + '<button href="#" class="button action">' + options.buttonText + '</button></div>'; } var classes = 'curtain-tooltip tooltip in ' + (options.tooltipClass || ''); tooltip .classed(classes, true) .selectAll('.tooltip-inner') .html(html); if (options.buttonText && options.buttonCallback) { var button = tooltip.selectAll('.button-section .button.action'); button .on('click', function() { d3_event.preventDefault(); options.buttonCallback(); }); } var tip = copyBox(tooltip.node().getBoundingClientRect()), w = window.innerWidth, h = window.innerHeight, tooltipWidth = 200, tooltipArrow = 5, side, pos; // hack: this will have bottom placement, // so need to reserve extra space for the tooltip illustration. if (options.tooltipClass === 'intro-mouse') { tip.height += 80; } // trim box dimensions to just the portion that fits in the window.. if (tooltipBox.top + tooltipBox.height > h) { tooltipBox.height -= (tooltipBox.top + tooltipBox.height - h); } if (tooltipBox.left + tooltipBox.width > w) { tooltipBox.width -= (tooltipBox.left + tooltipBox.width - w); } // determine tooltip placement.. if (tooltipBox.top + tooltipBox.height < 100) { // tooltip below box.. side = 'bottom'; pos = [ tooltipBox.left + tooltipBox.width / 2 - tip.width / 2, tooltipBox.top + tooltipBox.height ]; } else if (tooltipBox.top > h - 140) { // tooltip above box.. side = 'top'; pos = [ tooltipBox.left + tooltipBox.width / 2 - tip.width / 2, tooltipBox.top - tip.height ]; } else { // tooltip to the side of the tooltipBox.. var tipY = tooltipBox.top + tooltipBox.height / 2 - tip.height / 2; if (textDirection === 'rtl') { if (tooltipBox.left - tooltipWidth - tooltipArrow < 70) { side = 'right'; pos = [tooltipBox.left + tooltipBox.width + tooltipArrow, tipY]; } else { side = 'left'; pos = [tooltipBox.left - tooltipWidth - tooltipArrow, tipY]; } } else { if (tooltipBox.left + tooltipBox.width + tooltipArrow + tooltipWidth > w - 70) { side = 'left'; pos = [tooltipBox.left - tooltipWidth - tooltipArrow, tipY]; } else { side = 'right'; pos = [tooltipBox.left + tooltipBox.width + tooltipArrow, tipY]; } } } if (options.duration !== 0 || !tooltip.classed(side)) { tooltip.call(uiToggle(true)); } tooltip .style('top', pos[1] + 'px') .style('left', pos[0] + 'px') .attr('class', classes + ' ' + side); // shift tooltip-inner if it is very close to the top or bottom edge // (doesn't affect the placement of the tooltip-arrow) var shiftY = 0; if (side === 'left' || side === 'right') { if (pos[1] < 60) { shiftY = 60 - pos[1]; } else if (pos[1] + tip.height > h - 100) { shiftY = h - pos[1] - tip.height - 100; } } tooltip.selectAll('.tooltip-inner') .style('top', shiftY + 'px'); } else { tooltip .classed('in', false) .call(uiToggle(false)); } curtain.cut(box, options.duration); return tooltip; }; curtain.cut = function(datum, duration) { darkness.datum(datum) .interrupt(); var selection; if (duration === 0) { selection = darkness; } else { selection = darkness .transition() .duration(duration || 600) .ease(d3_easeLinear); } selection .attr('d', function(d) { var string = 'M 0,0 L 0,' + window.innerHeight + ' L ' + window.innerWidth + ',' + window.innerHeight + 'L' + window.innerWidth + ',0 Z'; if (!d) return string; return string + 'M' + d.left + ',' + d.top + 'L' + d.left + ',' + (d.top + d.height) + 'L' + (d.left + d.width) + ',' + (d.top + d.height) + 'L' + (d.left + d.width) + ',' + (d.top) + 'Z'; }); }; curtain.remove = function() { surface.remove(); tooltip.remove(); d3_select(window).on('resize.curtain', null); }; // ClientRects are immutable, so copy them to an object, // in case we need to trim the height/width. function copyBox(src) { return { top: src.top, right: src.right, bottom: src.bottom, left: src.left, width: src.width, height: src.height }; } return curtain; }