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

import { easeLinear as d3_easeLinear } from 'd3-ease'; import { select as d3_select } from 'd3-selection'; import { localizer } from '../core/localizer'; import { uiToggle } from './toggle'; // Tooltips and svg mask used to highlight certain features export function uiCurtain(containerNode) { var surface = d3_select(null), tooltip = d3_select(null), darkness = d3_select(null); function curtain(selection) { surface = selection .append('svg') .attr('class', 'curtain') .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'); tooltip .append('div') .attr('class', 'popover-arrow'); tooltip .append('div') .attr('class', 'popover-inner'); resize(); function resize() { surface .attr('width', containerNode.clientWidth) .attr('height', containerNode.clientHeight); 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 {function} [options.padding] extra margin in px to put around bbox * @param {String|ClientRect} [options.tooltipBox] box for tooltip position, if different from box for the curtain */ curtain.reveal = function(box, html, options) { options = options || {}; if (typeof box === 'string') { box = d3_select(box).node(); } if (box && box.getBoundingClientRect) { box = copyBox(box.getBoundingClientRect()); var containerRect = containerNode.getBoundingClientRect(); box.top -= containerRect.top; box.left -= containerRect.left; } if (box && options.padding) { box.top -= options.padding; box.left -= options.padding; box.bottom += options.padding; box.right += options.padding; box.height += options.padding * 2; box.width += options.padding * 2; } 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 && html) { if (html.indexOf('**') !== -1) { if (html.indexOf('<span') === 0) { html = html.replace(/^(<span.*?>)(.+?)(\*\*)/, '$1<span>$2</span>$3'); } else { html = html.replace(/^(.+?)(\*\*)/, '<span>$1</span>$2'); } // pseudo markdown bold text for the instruction section.. html = html.replace(/\*\*(.*?)\*\*/g, '<span class="instruction">$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 popover tooltip arrowed in ' + (options.tooltipClass || ''); tooltip .classed(classes, true) .selectAll('.popover-inner') .html(html); if (options.buttonText && options.buttonCallback) { var button = tooltip.selectAll('.button-section .button.action'); button .on('click', function(d3_event) { d3_event.preventDefault(); options.buttonCallback(); }); } var tip = copyBox(tooltip.node().getBoundingClientRect()), w = containerNode.clientWidth, h = containerNode.clientHeight, 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 container.. 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 (localizer.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 popover-inner if it is very close to the top or bottom edge // (doesn't affect the placement of the popover-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('.popover-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 containerWidth = containerNode.clientWidth; var containerHeight = containerNode.clientHeight; var string = 'M 0,0 L 0,' + containerHeight + ' L ' + containerWidth + ',' + containerHeight + 'L' + containerWidth + ',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; }