/** * Initialize the touchkey setup. * * Requires an empty SVG container, and a HTML input tag (recommended: type="hidden") to write the string representation * to. Can additionally be provided with the ID of a HTML form which should be auto-submitted after touchkey entry. * * Example: * * <form id="touchkey-form" method="post" action="/login"> * <svg id="touchkey-svg"></svg> * <input type="hidden" name="touchkey" id="touchkey-field" /> * </form> * <script> * initTouchkey(false, "touchkey-svg", "touchkey-form", "touchkey-field"); * </script> * * @param {boolean} keepPattern: Whether to keep the pattern on screen, or to clear it after the end event. * @param {string} svgid: HTML id of the SVG container the touchkey is drawn in. * @param {string} formid: HTML id of the form that should be submitted after touchkey entry. null to disable * auto-submit. * @param {string} formfieldid: ID of the input object that should receive the entered touchkey as its value. */ initTouchkey = (keepPattern, svgid, formid, formfieldid) => { // Define forEach (iteration) and slice (abused for shallow copying) on HTMLCollections (reuse the Array methods) HTMLCollection.prototype.forEach = Array.prototype.forEach; HTMLCollection.prototype.slice = Array.prototype.slice; // Max. distance to a nodes center, before the path snaps to the node. // Expressed as inverse ratio of the container width const SNAPPING_SENSITIVITY = 12; // Get the DOM objects for the SVG, the form and the input field let svg = document.getElementById(svgid); let form; if (formid !== null) { form = document.getElementById(formid); } let formfield = document.getElementById(formfieldid); // Reference to the SVG line object that's currently being drawn by the user, or null, if none let currentStroke = null; // ID generator for the SVG line objects drawn by the user let strokeId = 0; // Set of touchkey pattern nodes that have already been visited by a users pattern, and may not be reused again let doneMap = {}; // The string representation of the touchkey entered by the user. let enteredKey = ''; /** * Helper function to create a new stroke after the user completed one stroke by connecting two pattern nodes. * * @param {number} fromX: X coordinate of the starting point of this line. * @param {number} fromY: Y coordinate of the starting point of this line. * @param {number} toX: X coordinate of the ending point of this line. * @param {number} toY: Y coordinate of the ending point of this line. * @returns {string} The ID of the generated line object. */ let drawLine = (fromX, fromY, toX, toY) => { // Create a new SVG line object let line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); // Generate and set an unique ID for the line object let id = 'touchkey-svg-stroke-' + (strokeId++); line.setAttribute('id', id); // Create and set the HTML class attribute line.setAttribute('class', 'touchkey-svg-stroke'); // Create and set the coordinate attributes line.setAttribute('x1', fromX.toString()); line.setAttribute('y1', fromY.toString()); line.setAttribute('x2', toX.toString()); line.setAttribute('y2', toY.toString()); // Create and set the style attribute (grey, circular ends, 5% width) line.setAttribute('style', 'stroke: grey; stroke-width: 5%; stroke-linecap: round'); // Add the line to the SVG svg.appendChild(line); // Return the previously generated ID to identify the line object by return id; }; /** * Helper function used to remove the "trailing stroke" (i.e. after the user let go, there is a dangling stroke from * the node that was hit last to the mouse pointer/finger). */ let endPath = () => { // Remove the current stroke ... svg.removeChild(svg.getElementById(currentStroke)); // ... and set its reference to null currentStroke = null; }; /** * Helper function used to clear the touchkey pattern drawn by the user. */ let clearTouchkey = () => { // Reset the set of visited pattern nodes doneMap = {}; // Reset the touchkey string representation enteredKey = ''; // Remove all line objects. Create a shallow copy of the list first to retain deletion order. svg.getElementsByClassName('touchkey-svg-stroke').slice().forEach((line) => { svg.removeChild(line); }); }; /** * Helper function to read and convert the event coordinates of a MouseEvent or TouchEvent to coordinates relative * to the SVG's origin. * * @param {(MouseEvent|TouchEvent)} ev: The event to get the X and Y coordinates from. * @param {(ClientRect|DOMRect)} svgrect: Bounds rectangle of the SVG container. * @returns {Array} The X and Y coordinates relative to the SVG's origin. */ let getEventCoordinates = (ev, svgrect) => { // Check for existence of the "touches" property to distinguish between touch and mouse events // For a touch event, take the page coordinates of the first touch // For a mouse event, take the event coordinates // Then subtract the SVG's origin from the page-relative coordinates to obtain the translated coordinates const trX = (typeof ev.touches !== 'undefined' ? ev.touches[0].pageX : ev.x) - svgrect.left; const trY = (typeof ev.touches !== 'undefined' ? ev.touches[0].pageY : ev.y) - svgrect.top; return [trX, trY] }; /** * Find the pattern node closest to a coordinate. * * @param {number} evX: X coordinate of the point to search the closest node for. * @param {number} evY: Y coordinate of the point to search the closest node for. * @param {(ClientRect|DOMRect)} svgrect: Bounds rectangle of the SVG container. * @returns {Array} The node's ID, the distance, the X and Y coordinate of the node's center. */ let getNearestPatternNode = (evX, evY, svgrect) => { // Initialize nearest neighbors search variables let minId = ''; let minDist2 = Infinity; // Squared distance let minX = 0; let minY = 0; // Iterate the pattern nodes for nearest neighbors search document.getElementsByClassName('touchkey-svg-node').forEach((node) => { // Get the position of a node's center, converted from ratio into absolute pixel count let x = parseFloat(node.getAttribute('cx')) / 100.0 * svgrect.width; let y = parseFloat(node.getAttribute('cy')) / 100.0 * svgrect.height; // Compute the squared distance from the event coordinate to the node's center let dist2 = Math.pow(evX - x, 2) + Math.pow(evY - y, 2); // Keep the properties of the closest node if (dist2 < minDist2) { minDist2 = dist2; minId = node.dataset.stringRepresentation; minX = x; minY = y; } }); return [minId, Math.sqrt(minDist2), minX, minY]; }; /** * Event handler for "mouse down" / "touch down" events. * * Selects an "anchor node", i.e. the node where the pattern path started, and draws a line from there to the event * coordinates. */ svg.ontouchstart = svg.onmousedown = (ev) => { // Remove any previous strokes that may still be there if "keepPattern" was set to true in the init call clearTouchkey(); // Get the SVG container's rectangle const svgrect = svg.getBoundingClientRect(); // Get the event coordinates relative to the SVG container's origin const [trX, trY] = getEventCoordinates(ev, svgrect); // Get the closest pattern node const [minId, _, minX, minY] = getNearestPatternNode(trX, trY, svgrect); // Create the line from the anchor node to the event position currentStroke = drawLine(minX, minY, trX, trY); // Mark the anchor node as visited doneMap[minId] = 1; // Add the anchor node's string representation to the touchkey string representation enteredKey += minId; }; /** * Event handler for "mouse move" / "touch move" events. */ svg.ontouchmove = svg.onmousemove = (ev) => { // Only act if the user started is drawing a pattern (only relevant for mouse input) if (currentStroke != null) { // Get the SVG container's rectangle const svgrect = svg.getBoundingClientRect(); // Get the event coordinates relative to the SVG container's origin const [trX, trY] = getEventCoordinates(ev, svgrect); // Get the closest pattern node const [minId, minDist, minX, minY] = getNearestPatternNode(trX, trY, svgrect); // If the closest node is not visited yet, and the event coordinate is less than from the node's // center, snap the current stroke to the node, and create a new stroke starting from this node if (minDist < (svgrect.width / SNAPPING_SENSITIVITY) && !(minId in doneMap)) { // Snap the current stroke to the node let line = svg.getElementById(currentStroke); line.setAttribute('x2', minX.toString()); line.setAttribute('y2', minY.toString()); // Create a new line object from the closest node to the event position currentStroke = drawLine(minX, minY, trX, trY); // Mark the closest node as visited doneMap[minId] = 1; // Append its string representation to the touchkey string representation enteredKey += minId; } else { // If the stroke was not snapped to the closest node, update its end position let line = svg.getElementById(currentStroke); line.setAttribute('x2', trX); line.setAttribute('y2', trY); } } }; /** * Event handler for "mouse up" / "touch end" events. * * Sets the input object value, and optionally clears the SVG path and submits the form. */ svg.ontouchend = svg.onmouseup = () => { // Remove the trailing, unfinished stroke endPath(); // Write the touchkey string representation to the input field formfield.value = enteredKey; // Erase the touchkey pattern, if requested in the init call if (keepPattern !== true) { clearTouchkey(); } // Submit the HTML form, if requested in the init call if (formid !== null) { form.submit(); } }; /* * Create the SVG touchkey nodes */ // Node ID provider let touchkey_node_counter = 0; // Percentages for centers of quarters of the container's width and height ['12.5%', '37.5%', '62.5%', '87.5%'].forEach((y) => { ['12.5%', '37.5%', '62.5%', '87.5%'].forEach((x) => { // Create a new pattern node let node = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); // Generate a new ID (and the touchkey string representation from it) let id = touchkey_node_counter++; node.dataset.stringRepresentation = id.toString(16).toLowerCase(); // Class node.setAttribute('class', 'touchkey-svg-node'); // Center node.setAttribute('cx', x); node.setAttribute('cy', y); // Radius node.setAttribute('r', '10%'); // Center color node.setAttribute('fill', 'white'); // Circle color node.setAttribute('stroke', 'grey'); // Circle width node.setAttribute('stroke-width', '2%'); // Add the node to the SVG container svg.appendChild(node); }); }); };