/* global Snap mina */
'use strict';
const {assign} = Object;
const {cos, sin, sqrt, PI} = Math;
const {debounce, isEmpty} = require('./common');
const BORDER = 1;
const GUTTER = 4;
const NUMBER_OF_SIDES = 6;
const ADJACENT_ANGLE = PI / NUMBER_OF_SIDES;
const DEBOUNCE_SCALING_FACTOR = 2.5;
let preventDefault = e => e.preventDefault();
let translateX = item => val => item.attr({transform: `translateX(${val})`});
/**
* @function hexagonalMenu
* @description Create a vertical menu with animations and events
* @param {object[]} items Menu items
* @param {object} options Options to configure
* @param {number} options.sideLength Length of hexagonal side in pixels
* @param {number} options.width Item width
* @param {number} [options.duration=100] Duration for hide and show animations (in milliseconds)
* @returns {function} hexagonalMenu
* @example <caption>Create a menu</caption>
* let paper = Snap('svg');
* let height = '100%';
* let width = '100%';
* paper.attr({height, width});
* let options = {
* sideLength: 30,
* width: 210
* };
* let items = [
* {title: 'FRUITS', submenu: [
* {title: 'Apple'},
* {title: 'Pear'},
* {title: 'Banana'}
* ]},
* {title: 'VEGETABLES', submenu: [
* {title: 'Broccoli'},
* {title: 'Carrot'},
* {title: 'Spinach'}
* ]},
* {title: 'MEATS', submenu: [
* {title: 'Chicken'},
* {title: 'Beef'},
* {title: 'Pork', subtitle: 'The other white meat'}
* ]}
* ];
* let menu = paper.hexagonalMenu(items, options);
* @example <caption>Add event listeners to the menu</caption>
* menu.on('before:show', e => {
* //triggered before show animation
* });
* menu.on('show', e => {
* //triggered after show animation is complete
* });
* menu.on('before:hide', e => {
* //triggered before hide animation starts
* });
* menu.on('hide', e => {
* //triggered after hide animation is complete
* });
* menu.on('before:reset', e => {
* //triggered before reset animation starts
* });
* menu.on('reset', e => {
* //triggered after reset animation is complete
* });
* @example <caption>Add event listeners to buttons</caption>
* //All event names with numbers are the zero-indexed numbers of the associated buttons
* menu.on('click:0', e => {
* //triggered when the 'FRUITS' button is clicked
* });
* //Access passed event options via e.detail
* menu.on('click:child', e => {
* //triggered when any child item is clicked
* console.log(e.detail.target.title.attr('text'));
* });
* menu.on('click:2:child', e => {
* //triggered when any child of 'MEATS' is clicked
* });
* menu.on('click:1:0', e => {
* //triggered when 'Broccoli' is clicked
* });
**/
module.exports = function(items, options) {
let visible = false;
let paper = assign(this, {visible});
let isVisible = () => paper.visible;
let {duration, sideLength, width} = (options || {});
let buttons = [];
const DURATION = duration || 100;
let α = sideLength * sin(ADJACENT_ANGLE);// vertical diagonal height
let β = sideLength * cos(ADJACENT_ANGLE);// horizontal diagonal width
let H = sideLength + α;
let menuItemWidth = width + β + (2 * GUTTER) + (2 * BORDER);
let menuWidth = menuItemWidth + β + (2 * GUTTER);
let onClick = button => e => {
e.preventDefault();
let menu = paper;
let parent = null;
let target = buttons[button.index];
let {index, isActive, hasSubmenu} = target;
trigger('click:' + index, {menu, parent, target});
if (isActive) {
reset.bind(paper)();
} else if (hasSubmenu) {
target.blur();
showSubmenuFor(target);
}
};
function rowHeight(m) {
return m > 0 ? m * (sideLength + sqrt(2) * GUTTER) + (m - 1) * α : -α;
}
function createButtons() {
buttons = items.map(function(button, index) {
var hasSubmenu = Array.isArray(items[index].submenu) && items[index].submenu.length > 0;
button.sideLength = sideLength;
button.width = width;
button.x = 0 - menuItemWidth;
button.y = α + index * (H + GUTTER + 2 * BORDER);
button.level = hasSubmenu ? 0 : 0;
return paper.hexagonalButton(button);
});
return buttons;
}
function setupMenu() {
createButtons().forEach((button, index) => {
let submenu = [];
let hasSubmenu = !isEmpty(items[index].submenu);
assign(button.node, {
onblur: preventDefault,
oncontextmenu: preventDefault
});
enableInteraction(assign(button, {index, submenu, hasSubmenu}));
});
}
function reset() {
let DURATION = 50;
let menu = paper;
trigger('before:reset', {menu});
buttons.forEach(button => {
if (button.submenu.length > 0) {
button.submenu.forEach(item => item.remove());
button.submenu = [];
}
button.isActive = false;
var start = paper.visible ? menuWidth : -menuWidth;
var stop = paper.visible ? menuWidth : -menuWidth;
Snap.animate(start, stop, translateX(button), DURATION, mina.linear, () => {
enableInteraction(button.enable());
if (button.index === (buttons.length - 1)) {trigger('reset', {menu});}
});
});
}
function showMenu(duration) {
if (!paper.visible) {
buttons.forEach(function(button, index) {
setTimeout(function() {
Snap.animate(0, menuWidth, translateX(button), duration, mina.easein, () => {
let menu = paper;
if (index === (items.length - 1)) {
paper.visible = true;
trigger('show', {menu});
} else if (index === 0) {
trigger('before:show', {menu});
}
});
}, ((duration / 2) * index));
});
}
}
function showSubmenuFor(button) {
let DURATION = 50;
let thisButton = assign(button, {
isActive: true,
submenu: []
});
let {index} = thisButton;
let {submenu} = items[index];
let x0 = menuWidth - β;
let y0 = α + index * (H + GUTTER + 2 * BORDER);
thisButton.addClass('active').disableHoverAnimation();
thisButton.submenu = submenu.map((item, i) => {
let {title, subtitle} = item;
let x = x0 + 0 * BORDER + 0 * β - (2 * GUTTER) + menuWidth;
let y = y0 + rowHeight(i + 1);
let level = 0;
let opacity = 0;
return paper
.hexagonalButton({x, y, title, subtitle, sideLength, width, level})
.attr({opacity});
});
thisButton.submenu.forEach((item, i) => {
item.index = i;
let options = {
menu: paper,
parent: thisButton,
target: item
};
let eventName = `click:${index}:`;
let triggerEvents = () => {
trigger('click:child', options);
trigger(eventName + 'child', options);
trigger(eventName + i, options);
};
setTimeout(() => {
let opacity = 1;
assign(item.node, {
onclick: triggerEvents,
ontouchstart: triggerEvents,
oncontextmenu: preventDefault
});
item.attr({opacity});
Snap.animate(0, -menuWidth, translateX(item), DURATION, mina.easein, () => {
if (items[i].submenu && (i === (items[i].submenu.length - 1))) {
trigger('show:submenu', options);
}
});
}, DURATION * i);
});
buttons.filter((e, i) => (i !== index)).forEach(button => {
let start = menuWidth;
let stop = menuItemWidth + GUTTER + BORDER;
Snap.animate(start, stop, translateX(button), DURATION, mina.linear, () => {
disableInteraction(button.disable());
});
});
}
function enableInteraction(button) {
assign(button.node, {
onclick: onClick(button),
ontouchstart: onClick(button),
onmousedown: button.focus
});
}
function disableInteraction(button) {
assign(button.node, {
onclick: preventDefault,
ontouchstart: preventDefault,
onmousedown: preventDefault
});
}
function hideMenu(duration) {
buttons.slice(0).reverse().forEach((button, i) => {
let _duration = duration + ((duration / 2) * i);
Snap.animate(menuWidth, 0, translateX(button), _duration, mina.backout, () => {
let menu = paper;
if (i === (items.length - 1)) {
paper.visible = false;
trigger('hide', {menu});
} else if (i === 0) {
trigger('before:hide', {menu});
}
});
});
}
function closeMenu(duration) {
reset.bind(paper)();
hideMenu.bind(paper)(duration);
}
function trigger(name, options) {
var event;
if (window.CustomEvent) {
event = new CustomEvent(name, {detail: options});
} else {
event = document.createEvent('CustomEvent');
event.initCustomEvent(name, true, true, options);
}
paper.node.dispatchEvent(event);
}
let on = (event, handler) => paper.node.addEventListener(event, handler);
let off = (event, handler) => paper.node.removeEventListener(event, handler);
let toggleMenu = () => (isVisible() ? closeMenu : showMenu).bind(paper)(DURATION);
let toggle = debounce(toggleMenu, DURATION * DEBOUNCE_SCALING_FACTOR, true);
//
// Setup menu and return API
//
setupMenu();
return {on, off, buttons, isVisible, reset, toggle};
};