diff --git a/static/js/touchkey.js b/static/js/touchkey.js index d0f91ad..f799af5 100644 --- a/static/js/touchkey.js +++ b/static/js/touchkey.js @@ -1,9 +1,32 @@ - -HTMLCollection.prototype.forEach = Array.prototype.forEach; -HTMLCollection.prototype.slice = Array.prototype.slice; - +/** + * 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: + * + *
+ * + * + * @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; + + // Get the DOM objects for the SVG, the form and the input field let svg = document.getElementById(svgid); let form; if (formid !== null) { @@ -11,122 +34,231 @@ initTouchkey = (keepPattern, svgid, formid, formfieldid) => { } 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'); - let id = 'l-' + (strokeId++); - let idAttr = document.createAttribute('id'); - let classAttr = document.createAttribute('class'); - let x1attr = document.createAttribute('x1'); - let y1attr = document.createAttribute('y1'); - let x2attr = document.createAttribute('x2'); - let y2attr = document.createAttribute('y2'); - let styleAttr = document.createAttribute('style'); - idAttr.value = id; - classAttr.value = 'l'; - x1attr.value = fromX; - y1attr.value = fromY; - x2attr.value = toX; - y2attr.value = toY; - styleAttr.value = 'stroke: grey; stroke-width: 5%; stroke-linecap: round'; - line.setAttributeNode(idAttr); - line.setAttributeNode(classAttr); - line.setAttributeNode(x1attr); - line.setAttributeNode(y1attr); - line.setAttributeNode(x2attr); - line.setAttributeNode(y2attr); - line.setAttributeNode(styleAttr); + // 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 = ''; - svg.getElementsByClassName('l').slice().reverse().forEach((line) => { + // 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); }); }; - svg.ontouchstart = svg.onmousedown = (ev) => { - clearTouchkey(); - const svgrect = svg.getBoundingClientRect(); + /** + * 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 squared distance, the X and Y coordinate of the node's center. + */ + let getNearestPatternNode = (evX, evY, svgrect) => { + // Initialize nearest neighbors search variables let minId = ''; - let minDist = Infinity; - let minx = 0; - let miny = 0; - doneMap = {}; - document.getElementsByClassName('c').forEach((circle) => { - let x = parseFloat(circle.getAttribute('cx')) / 100.0 * svgrect.width; - let y = parseFloat(circle.getAttribute('cy')) / 100.0 * svgrect.height; - let dist = Math.pow(trX - x, 2) + Math.pow(trY - y, 2); - if (dist < minDist) { - minDist = dist; - minId = circle.id; - minx = x; - miny = y; + 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; } }); - currentStroke = drawLine(minx, miny, trX, trY); + + return [minId, 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; }; - svg.ontouchend = svg.onmouseup = (ev) => { + /** + * 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, minDist2, minX, minY] = getNearestPatternNode(trX, trY, svgrect); + // If the closest node is not visited yet, and the event coordinate is less than ~44px from the node's + // center, snap the current stroke to the node, and create a new stroke starting from this node + if (minDist2 < 2000 && !(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(); } }; - svg.ontouchmove = svg.onmousemove = (ev) => { - const svgrect = svg.getBoundingClientRect(); - 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; - console.log(trY); - if (currentStroke != null) { - let minId = ''; - let minDist = Infinity; - let minx = 0; - let miny = 0; - document.getElementsByClassName('c').forEach((circle) => { - let x = parseFloat(circle.getAttribute('cx')) / 100.0 * svgrect.width; - let y = parseFloat(circle.getAttribute('cy')) / 100.0 * svgrect.height; - let dist = Math.pow(trX - x, 2) + Math.pow(trY - y, 2); - if (dist < minDist) { - minDist = dist; - minId = circle.id; - minx = x; - miny = y; - } - }); - if (minDist < 2000 && !(minId in doneMap)) { - let line = svg.getElementById(currentStroke); - line.setAttribute('x2', minx); - line.setAttribute('y2', miny); - currentStroke = drawLine(minx, miny, trX, trY); - doneMap[minId] = 1; - enteredKey += minId; - } - let line = svg.getElementById(currentStroke); - line.setAttribute('x2', trX); - line.setAttribute('y2', trY); - } - }; + /* + * 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); + }); + }); }; diff --git a/templates/admin_all.html b/templates/admin_all.html index b977987..56dba36 100644 --- a/templates/admin_all.html +++ b/templates/admin_all.html @@ -54,7 +54,7 @@