From 2e16e0ad1580371cd6d7771b326b23899126b14c Mon Sep 17 00:00:00 2001 From: Saul Johnson Date: Sat, 16 May 2020 14:39:42 +0100 Subject: [PATCH] Enable rotation of molecules --- src/MoleculeRenderer.php | 34 +- src/coffee/main.coffee | 58 +- src/coffee/rotationknob.coffee | 28 + src/less/variables.less | 2 +- templates/index.html.twig | 34 +- web/index.php | 16 +- web/js-lib/pureknob.js | 1123 ++++++++++++++++++++++++++++++++ 7 files changed, 1261 insertions(+), 34 deletions(-) create mode 100644 src/coffee/rotationknob.coffee create mode 100644 web/js-lib/pureknob.js diff --git a/src/MoleculeRenderer.php b/src/MoleculeRenderer.php index bd83ec1..c3d3f96 100644 --- a/src/MoleculeRenderer.php +++ b/src/MoleculeRenderer.php @@ -16,9 +16,11 @@ class MoleculeRenderer private $sourireUrl; private $renderChiralLabels; - + private $customLabel; - + + private $rotation; + /** * Initializes a new instance of a molecule rendering service. * @@ -50,8 +52,8 @@ class MoleculeRenderer public function renderMolecule($smiles, $color) { // Proxy into Sourire for molecule render. - $img = Image::make($this->sourireUrl - . 'molecule/' . rawurlencode($smiles) + $img = Image::make($this->sourireUrl + . 'molecule/' . rawurlencode($smiles) . '?render-stereo-style=' . ($this->renderChiralLabels ? 'old' : 'none') // Label chiral atoms? . '&render-comment-offset=16' // Give some space between label and molecule. . ($this->customLabel === '' ? '' : ('&render-comment=' . rawurlencode($this->customLabel)))); @@ -60,6 +62,7 @@ class MoleculeRenderer list($r, $g, $b) = sscanf($color, "%02x%02x%02x"); $unit = 100 / 255; $img->colorize($unit * $r, $unit * $g, $unit * $b); + $img->rotate($this->rotation); return $img; // Return molecule image. } @@ -72,6 +75,7 @@ class MoleculeRenderer * @param int $canvasWidth the width of the canvas to scale the molecule to * @param int $canvasHeight the height of the canvas to scale the molecule to * @param float $proportion the maximum proportion of the canvas the molecule should occupy (in either direction) + * @param float $rotation the angle to rotate the image by * @return \Intervention\Image\Image */ public function renderScaledMolecule($smiles, $color, $canvasWidth, $canvasHeight, $proportion = 0.8) @@ -165,4 +169,26 @@ class MoleculeRenderer $this->customLabel = $customLabel; return $this; } + + /** + * Gets the angle of rotation to apply to the molecule. + * + * @return float + */ + public function getRotation() + { + return $this->rotation; + } + + /** + * Sets the angle of rotation to apply to the molecule. + * + * @param float $rotation the angle of rotation to apply + * @return MoleculeRenderer + */ + public function setRotation($rotation) + { + $this->rotation = $rotation; + return $this; + } } diff --git a/src/coffee/main.coffee b/src/coffee/main.coffee index 49d5bdc..e025309 100644 --- a/src/coffee/main.coffee +++ b/src/coffee/main.coffee @@ -7,6 +7,9 @@ $(document).ready -> foregroundColor = 'ce3838' backgroundColor = '5f0000' + # Transforms. + rotation = 0 + # Custom label for molecule. customLabel = '' @@ -22,7 +25,7 @@ $(document).ready -> # Element to use for wallpaper preview. previewElement = $ 'body, html' - + # Download button element. downloadButton = $ '.download-btn' @@ -58,8 +61,10 @@ $(document).ready -> # buildUrl = (width, height, foreground, background, name) -> url = "/api/name/#{width}/#{height}/#{background}/#{foreground}/#{name}" - if customLabel != '' then url += "?label=#{customLabel}" - return url + qs = "" + if customLabel != '' then qs += "label=#{customLabel}" + if rotation != 0 then qs += "rotation=#{rotation}" + return if qs == "" then url else url + "?" + qs # Builds a URL to generate a molecule-only image from a compound name. # @@ -70,8 +75,10 @@ $(document).ready -> # buildMoleculeOnlyUrl = (width, height, foreground, name) -> url = "/api/name/#{width}/#{height}/#{foreground}/#{name}" - if customLabel != '' then url += "?label=#{customLabel}" - return url + qs = "" + if customLabel != '' then qs += "label=#{customLabel}" + if rotation != 0 then qs += "rotation=#{rotation}" + return if qs == "" then url else url + "?" + qs # Builds a URL to generate a wallpaper image from a SMILES structure. # @@ -83,8 +90,10 @@ $(document).ready -> # buildSmilesUrl = (width, height, foreground, background, smiles) -> url = "/api/smiles/#{width}/#{height}/#{background}/#{foreground}/#{smiles}" - if customLabel != '' then url += "?label=#{customLabel}" - return url + qs = "" + if customLabel != '' then qs += "label=#{customLabel}" + if rotation != 0 then qs += "rotation=#{rotation}" + return if qs == "" then url else url + "?" + qs # Builds a URL to generate a molecule-only image from a SMILES structure. # @@ -95,8 +104,10 @@ $(document).ready -> # buildSmilesMoleculeOnlyUrl = (width, height, foreground, smiles) -> url = "/api/smiles/#{width}/#{height}/#{foreground}/#{smiles}" - if customLabel != '' then url += "?label=#{customLabel}" - return url + qs = "" + if customLabel != '' then qs += "label=#{customLabel}" + if rotation != 0 then qs += "rotation=#{rotation}" + return if qs == "" then url else url + "?" + qs # Checks if a compound name can be converted to SMILES using the configured database. # @@ -153,7 +164,8 @@ $(document).ready -> params = { 'label': customLabel, 'background': backgroundColor, - 'foreground': foregroundColor + 'foreground': foregroundColor, + 'rotation': rotation } if smilesMode params['smiles'] = currentCompoundSmiles @@ -180,16 +192,16 @@ $(document).ready -> refreshPreviewCompoundName = -> currentCompoundName = getCompoundName() smilesMode = false - url = buildMoleculeOnlyUrl screenWidth, screenHeight, foregroundColor, currentCompoundName - updatePreview previewElement, url, backgroundColor + url = buildMoleculeOnlyUrl screenWidth, screenHeight, foregroundColor, currentCompoundName, rotation + updatePreview previewElement, url, backgroundColor, rotation # Refreshes the preview using the SMILES structure text box. # refreshPreviewSmiles = -> currentCompoundSmiles = getCompoundSmiles() smilesMode = true - url = buildSmilesMoleculeOnlyUrl screenWidth, screenHeight, foregroundColor, currentCompoundSmiles - updatePreview previewElement, url, backgroundColor + url = buildSmilesMoleculeOnlyUrl screenWidth, screenHeight, foregroundColor, currentCompoundSmiles, rotation + updatePreview previewElement, url, backgroundColor, rotation # Refreshes the preview using the compound name or SMILES structure text box depending on mode. # @@ -205,7 +217,7 @@ $(document).ready -> backgroundColor = passedBackground # Let's initialize the color pickers. - + $('.picker-fg').spectrum color: "##{foregroundColor}" clickoutFiresChange: true @@ -219,7 +231,7 @@ $(document).ready -> preferredFormat: 'hex' # Set up color picker change events. - + $('.picker-fg').on 'change', (e) -> foregroundColor = $(e.target).val().substring(1) modeAwareRefreshPreview() @@ -227,9 +239,9 @@ $(document).ready -> $('.picker-bg').on 'change', (e) -> backgroundColor = $(e.target).val().substring(1) modeAwareRefreshPreview() - + # Compound name update button should refresh the preview. - + $('.update-btn').on 'click', (e) -> errorRows.hide() checkMoleculeName getCompoundName(), refreshPreviewCompoundName, failPreview @@ -267,5 +279,15 @@ $(document).ready -> currentCompoundSmiles = smilesTextBox.val() customLabel = customLabelTextBox.val() + window.knobListener = (k, v) -> + rotation = v + modeAwareRefreshPreview() + initRotationKnob() + + passedRotation = getParameterByName 'rotation' + if passedRotation != null + rotationKnob.setValue(passedRotation) + rotation = rotationKnob.getValue() + # Initial update. modeAwareRefreshPreview() diff --git a/src/coffee/rotationknob.coffee b/src/coffee/rotationknob.coffee new file mode 100644 index 0000000..f6b8f63 --- /dev/null +++ b/src/coffee/rotationknob.coffee @@ -0,0 +1,28 @@ +### + * Demo code for knob element. +### + +initRotationKnob = -> + # Create knob element, 300 x 300 px in size. + knob = pureknob.createKnob(88, 88) + # Set properties. + knob.setProperty 'angleStart', 0 + knob.setProperty 'angleEnd', Math.PI * 2 + knob.setProperty 'colorFG', '#C0C0C0' + knob.setProperty 'colorBG', '#505050' + knob.setProperty 'trackWidth', 0.4 + knob.setProperty 'valMin', 0 + knob.setProperty 'valMax', 359 + knob.setProperty 'needle', true + # Set initial value. + knob.setValue 0 + + knob.addListener window.knobListener + # Create element node. + node = knob.node() + # Add it to the DOM. + elem = document.getElementById('rotation_knob') + elem.appendChild node + window.rotationKnob = knob + +window.initRotationKnob = initRotationKnob diff --git a/src/less/variables.less b/src/less/variables.less index 5515ca7..eb6e015 100644 --- a/src/less/variables.less +++ b/src/less/variables.less @@ -28,7 +28,7 @@ @picker-border: #444; // Width variables. -@sidebar-width: 250px; +@sidebar-width: 400px; @width2: 100px; @full-width: 100%; diff --git a/templates/index.html.twig b/templates/index.html.twig index 659234f..81d776d 100644 --- a/templates/index.html.twig +++ b/templates/index.html.twig @@ -34,6 +34,8 @@ + + {% include "_analytics.html.twig" %} @@ -110,24 +112,38 @@
-
Colours
+
Colors and Rotation

-
-
Molecule:
+
+
+
+
Molecule:
+
+
+ +
+
+
+
+
Background:
+
+
+ +
+
-
- +
+
+
+
-
Background:
-
-
- +
diff --git a/web/index.php b/web/index.php index 8985992..80ff4f4 100644 --- a/web/index.php +++ b/web/index.php @@ -14,7 +14,7 @@ use Avogadrio\MoleculeRenderer; $app = new Silex\Application(); // Uncomment the line below while debugging your app. -// $app['debug'] = true; +$app['debug'] = true; // Load config. $config = Spyc::YAMLLoad(__DIR__ . '/../config/config.yaml'); @@ -52,6 +52,9 @@ $app->get('/api/smiles/{width}/{height}/{background}/{foreground}/{smiles}', // Add label. $moleculeRenderer->setCustomLabel($request->get('label')); + // Add rotation. + $moleculeRenderer->setRotation((float) $request->get('rotation')); + // Render molecule with background. $image = $moleculeRenderer->renderMoleculeWithBackground($smiles, $foreground, $background, $width, $height); @@ -68,6 +71,9 @@ $app->get('/api/smiles/{width}/{height}/{color}/{smiles}', // Add label. $moleculeRenderer->setCustomLabel($request->get('label')); + // Add rotation. + $moleculeRenderer->setRotation((float) $request->get('rotation')); + // Render molecule only. $image = $moleculeRenderer->renderScaledMolecule($smiles, $color, $width, $height); @@ -80,7 +86,7 @@ $app->get('/api/smiles/{width}/{height}/{color}/{smiles}', */ $app->get('/api/name/{width}/{height}/{background}/{foreground}/{name}', function (Request $request, $width, $height, $background, $foreground, $name) use ($app, $moleculeRenderer, $smilesConverter) { - + // Convert chemical name to SMILES if we can. $smiles = $smilesConverter->nameToSmiles($name); @@ -90,6 +96,9 @@ $app->get('/api/name/{width}/{height}/{background}/{foreground}/{name}', // Add label. $moleculeRenderer->setCustomLabel($request->get('label')); + // Add rotation. + $moleculeRenderer->setRotation((float) $request->get('rotation')); + // Render molecule with background. $image = $moleculeRenderer->renderMoleculeWithBackground($smiles, $foreground, $background, $width, $height); @@ -116,6 +125,9 @@ $app->get('/api/name/{width}/{height}/{color}/{name}', // Add label. $moleculeRenderer->setCustomLabel($request->get('label')); + // Add rotation. + $moleculeRenderer->setRotation((float) $request->get('rotation')); + // Render molecule only. $image = $moleculeRenderer->renderScaledMolecule($smiles, $color, $width, $height); diff --git a/web/js-lib/pureknob.js b/web/js-lib/pureknob.js new file mode 100644 index 0000000..cf19775 --- /dev/null +++ b/web/js-lib/pureknob.js @@ -0,0 +1,1123 @@ +/* + * pure-knob + * + * Canvas-based JavaScript UI element implementing touch, + * keyboard, mouse and scroll wheel support. + * + * Copyright 2018 - 2020 Andre Plötze + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use strict"; + +/* + * Custom user interface elements for pure knob. + */ +function PureKnob() { + + /* + * Creates a bar graph element. + */ + this.createBarGraph = function(width, height) { + const heightString = height.toString(); + const widthString = width.toString(); + const canvas = document.createElement('canvas'); + const div = document.createElement('div'); + div.style.display = 'inline-block'; + div.style.height = heightString + 'px'; + div.style.position = 'relative'; + div.style.textAlign = 'center'; + div.style.width = widthString + 'px'; + div.appendChild(canvas); + + /* + * The bar graph object. + */ + const graph = { + '_canvas': canvas, + '_div': div, + '_height': height, + '_width': width, + + /* + * Properties of this bar graph. + */ + '_properties': { + 'colorBG': '#181818', + 'colorFG': '#ff8800', + 'colorMarkers': '#888888', + 'markerStart': 0, + 'markerEnd': 100, + 'markerStep': 20, + 'trackWidth': 0.5, + 'valMin': 0, + 'valMax': 100, + 'valPeaks': [], + 'val': 0 + }, + + /* + * Returns the peak values for this bar graph. + */ + 'getPeaks': function() { + const properties = this._properties; + const peaks = properties.valPeaks; + const numPeaks = peaks.length; + const peaksCopy = []; + + /* + * Iterate over the peak values and copy them. + */ + for (let i = 0; i < numPeaks; i++) { + const peak = peaks[i]; + peaksCopy.push(peak); + } + + return peaksCopy; + }, + + /* + * Returns the value of a property of this bar graph. + */ + 'getProperty': function(key) { + const properties = this._properties; + const value = properties[key]; + return value; + }, + + /* + * Returns the current value of the bar graph. + */ + 'getValue': function() { + const properties = this._properties; + const value = properties.val; + return value; + }, + + /* + * Return the DOM node representing this bar graph. + */ + 'node': function() { + const div = this._div; + return div; + }, + + /* + * Redraw the bar graph on the canvas. + */ + 'redraw': function() { + this.resize(); + const properties = this._properties; + const colorTrack = properties.colorBG; + const colorFilling = properties.colorFG; + const colorMarkers = properties.colorMarkers; + const markerStart = properties.markerStart; + const markerEnd = properties.markerEnd; + const markerStep = properties.markerStep; + const trackWidth = properties.trackWidth; + const valMin = properties.valMin; + const valMax = properties.valMax; + const peaks = properties.valPeaks; + const value = properties.val; + const height = this._height; + const width = this._width; + const lineWidth = Math.round(trackWidth * height); + const halfWidth = 0.5 * lineWidth; + const centerY = 0.5 * height; + const lineTop = centerY - halfWidth; + const lineBottom = centerY + halfWidth; + const relativeValue = (value - valMin) / (valMax - valMin); + const fillingEnd = width * relativeValue; + const numPeaks = peaks.length; + const canvas = this._canvas; + const ctx = canvas.getContext('2d'); + + /* + * Clear the canvas. + */ + ctx.clearRect(0, 0, width, height); + + /* + * Check if markers should be drawn. + */ + if ((markerStart !== null) & (markerEnd !== null) & (markerStep !== null) & (markerStep !== 0)) { + ctx.lineCap = 'butt'; + ctx.lineWidth = '2'; + ctx.strokeStyle = colorMarkers; + + /* + * Draw the markers. + */ + for (let v = markerStart; v <= markerEnd; v += markerStep) { + const relativePos = (v - valMin) / (valMax - valMin); + const pos = Math.round(width * relativePos); + ctx.beginPath(); + ctx.moveTo(pos, 0); + ctx.lineTo(pos, height); + ctx.stroke(); + } + + } + + /* + * Draw the track. + */ + ctx.beginPath(); + ctx.rect(0, lineTop, width, lineWidth); + ctx.fillStyle = colorTrack; + ctx.fill(); + + /* + * Draw the filling. + */ + ctx.beginPath(); + ctx.rect(0, lineTop, fillingEnd, lineWidth); + ctx.fillStyle = colorFilling; + ctx.fill(); + + /* + * Prepare for drawing the peaks. + */ + ctx.strokeStyle = colorFilling; + + /* + * Draw the peaks. + */ + for (let i = 0; i < numPeaks; i++) { + const peak = peaks[i]; + const relativePeak = (peak - valMin) / (valMax - valMin); + const pos = Math.round(width * relativePeak); + ctx.beginPath(); + ctx.moveTo(pos, lineTop); + ctx.lineTo(pos, lineBottom); + ctx.stroke(); + } + + }, + + /* + * This is called as the canvas or the surrounding DIV is resized. + */ + 'resize': function() { + const canvas = this._canvas; + canvas.style.height = '100%'; + canvas.style.width = '100%'; + canvas.height = this._height; + canvas.width = this._width; + }, + + /* + * Sets the peak values of this bar graph. + */ + 'setPeaks': function(peaks) { + const properties = this._properties; + const peaksCopy = []; + const numPeaks = peaks.length; + + /* + * Iterate over the peak values and append them to the array. + */ + for (let i = 0; i < numPeaks; i++) { + const peak = peaks[i]; + peaksCopy.push(peak); + } + + this.setProperty('valPeaks', peaksCopy); + }, + + /* + * Sets the value of a property of this bar graph. + */ + 'setProperty': function(key, value) { + this._properties[key] = value; + this.redraw(); + }, + + /* + * Sets the value of this bar graph. + */ + 'setValue': function(value) { + const properties = this._properties; + const valMin = properties.valMin; + const valMax = properties.valMax; + + /* + * Clamp the actual value into the [valMin; valMax] range. + */ + if (value < valMin) { + value = valMin; + } else if (value > valMax) { + value = valMax; + } + + value = Math.round(value); + this.setProperty('val', value); + } + + }; + + /* + * This is called when the size of the canvas changes. + */ + const resizeListener = function(e) { + graph.redraw(); + }; + + canvas.addEventListener('resize', resizeListener); + return graph; + } + + /* + * Creates a knob element. + */ + this.createKnob = function(width, height) { + const heightString = height.toString(); + const widthString = width.toString(); + const smaller = width < height ? width : height; + const fontSize = 0.2 * smaller; + const fontSizeString = fontSize.toString(); + const canvas = document.createElement('canvas'); + const div = document.createElement('div'); + + div.style.display = 'inline-block'; + div.style.height = heightString + 'px'; + div.style.position = 'relative'; + div.style.textAlign = 'center'; + div.style.width = widthString + 'px'; + div.appendChild(canvas); + + const input = document.createElement('input'); + input.style.appearance = 'textfield'; + input.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; + input.style.border = 'none'; + input.style.color = '#ff8800'; + input.style.fontFamily = 'sans-serif'; + input.style.fontSize = fontSizeString + 'px'; + input.style.height = heightString + 'px'; + input.style.margin = 'auto'; + input.style.padding = '0px'; + input.style.textAlign = 'center'; + input.style.width = widthString + 'px'; + + const inputMode = document.createAttribute('inputmode'); + inputMode.value = 'numeric'; + input.setAttributeNode(inputMode); + + const inputDiv = document.createElement('div'); + inputDiv.style.bottom = '0px'; + inputDiv.style.display = 'none'; + inputDiv.style.left = '0px'; + inputDiv.style.position = 'absolute'; + inputDiv.style.right = '0px'; + inputDiv.style.top = '0px'; + inputDiv.appendChild(input); + div.appendChild(inputDiv); + + /* + * The knob object. + */ + const knob = { + '_canvas': canvas, + '_div': div, + '_height': height, + '_input': input, + '_inputDiv': inputDiv, + '_listeners': [], + '_mousebutton': false, + '_previousVal': 0, + '_timeout': null, + '_timeoutDoubleTap': null, + '_touchCount': 0, + '_width': width, + + /* + * Notify listeners about value changes. + */ + '_notifyUpdate': function() { + const properties = this._properties; + const value = properties.val; + const listeners = this._listeners; + const numListeners = listeners.length; + + /* + * Call all listeners. + */ + for (let i = 0; i < numListeners; i++) { + const listener = listeners[i]; + + /* + * Call listener, if it exists. + */ + if (listener !== null) { + listener(this, value); + } + + } + + }, + + /* + * Properties of this knob. + */ + '_properties': { + 'angleEnd': 2.0 * Math.PI, + 'angleOffset': -0.5 * Math.PI, + 'angleStart': 0, + 'colorBG': '#181818', + 'colorFG': '#ff8800', + 'colorLabel': '#ffffff', + 'fnStringToValue': function(string) { return parseInt(string); }, + 'fnValueToString': function(value) { return value.toString(); }, + 'label': null, + 'needle': false, + 'readonly': false, + 'textScale': 1.0, + 'trackWidth': 0.4, + 'valMin': 0, + 'valMax': 100, + 'val': 0 + }, + + /* + * Abort value change, restoring the previous value. + */ + 'abort': function() { + const previousValue = this._previousVal; + const properties = this._properties; + properties.val = previousValue; + this.redraw(); + }, + + /* + * Adds an event listener. + */ + 'addListener': function(listener) { + const listeners = this._listeners; + listeners.push(listener); + }, + + /* + * Commit value, indicating that it is no longer temporary. + */ + 'commit': function() { + const properties = this._properties; + const value = properties.val; + this._previousVal = value; + this.redraw(); + this._notifyUpdate(); + }, + + /* + * Returns the value of a property of this knob. + */ + 'getProperty': function(key) { + const properties = this._properties; + const value = properties[key]; + return value; + }, + + /* + * Returns the current value of the knob. + */ + 'getValue': function() { + const properties = this._properties; + const value = properties.val; + return value; + }, + + /* + * Return the DOM node representing this knob. + */ + 'node': function() { + const div = this._div; + return div; + }, + + /* + * Redraw the knob on the canvas. + */ + 'redraw': function() { + this.resize(); + const properties = this._properties; + const needle = properties.needle; + const angleStart = properties.angleStart; + const angleOffset = properties.angleOffset; + const angleEnd = properties.angleEnd; + const actualStart = angleStart + angleOffset; + const actualEnd = angleEnd + angleOffset; + const label = properties.label; + const value = properties.val; + const valueToString = properties.fnValueToString; + const valueStr = valueToString(value); + const valMin = properties.valMin; + const valMax = properties.valMax; + const relValue = (value - valMin) / (valMax - valMin); + const relAngle = relValue * (angleEnd - angleStart); + const angleVal = actualStart + relAngle; + const colorTrack = properties.colorBG; + const colorFilling = properties.colorFG; + const colorLabel = properties.colorLabel; + const textScale = properties.textScale; + const trackWidth = properties.trackWidth; + const height = this._height; + const width = this._width; + const smaller = width < height ? width : height; + const centerX = 0.5 * width; + const centerY = 0.5 * height; + const radius = 0.4 * smaller; + const labelY = centerY + radius; + const lineWidth = Math.round(trackWidth * radius); + const labelSize = Math.round(0.8 * lineWidth); + const labelSizeString = labelSize.toString(); + const fontSize = (0.2 * smaller) * textScale; + const fontSizeString = fontSize.toString(); + const canvas = this._canvas; + const ctx = canvas.getContext('2d'); + + /* + * Clear the canvas. + */ + ctx.clearRect(0, 0, width, height); + + /* + * Draw the track. + */ + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, actualStart, actualEnd); + ctx.lineCap = 'butt'; + ctx.lineWidth = lineWidth; + ctx.strokeStyle = colorTrack; + ctx.stroke(); + + /* + * Draw the filling. + */ + ctx.beginPath(); + + /* + * Check if we're in needle mode. + */ + if (needle) { + ctx.arc(centerX, centerY, radius, angleVal - 0.1, angleVal + 0.1); + } else { + ctx.arc(centerX, centerY, radius, actualStart, angleVal); + } + + ctx.lineCap = 'butt'; + ctx.lineWidth = lineWidth; + ctx.strokeStyle = colorFilling; + ctx.stroke(); + + /* + * Draw the number. + */ + ctx.font = fontSizeString + 'px sans-serif'; + ctx.fillStyle = colorFilling; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(valueStr, centerX, centerY); + + /* + * Draw the label + */ + if (label !== null) { + ctx.font = labelSizeString + 'px sans-serif'; + ctx.fillStyle = colorLabel; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, centerX, labelY); + } + + /* + * Set the color and font size of the input element. + */ + const elemInput = this._input; + elemInput.style.color = colorFilling; + elemInput.style.fontSize = fontSizeString + 'px'; + }, + + /* + * This is called as the canvas or the surrounding DIV is resized. + */ + 'resize': function() { + const canvas = this._canvas; + canvas.style.height = '100%'; + canvas.style.width = '100%'; + canvas.height = this._height; + canvas.width = this._width; + }, + + /* + * Sets the value of a property of this knob. + */ + 'setProperty': function(key, value) { + this._properties[key] = value; + this.redraw(); + }, + + /* + * Sets the value of this knob. + */ + 'setValue': function(value) { + this.setValueFloating(value); + this.commit(); + }, + + /* + * Sets floating (temporary) value of this knob. + */ + 'setValueFloating': function(value) { + const properties = this._properties; + const valMin = properties.valMin; + const valMax = properties.valMax; + + /* + * Clamp the actual value into the [valMin; valMax] range. + */ + if (value < valMin) { + value = valMin; + } else if (value > valMax) { + value = valMax; + } + + value = Math.round(value); + this.setProperty('val', value); + } + + }; + + /* + * Convert mouse event to value. + */ + const mouseEventToValue = function(e, properties) { + const canvas = e.target; + const width = canvas.scrollWidth; + const height = canvas.scrollHeight; + const centerX = 0.5 * width; + const centerY = 0.5 * height; + const x = e.offsetX; + const y = e.offsetY; + const relX = x - centerX; + const relY = y - centerY; + const angleStart = properties.angleStart; + const angleEnd = properties.angleEnd; + const angleDiff = angleEnd - angleStart; + let angle = Math.atan2(relX, -relY) - angleStart; + const twoPi = 2.0 * Math.PI; + + /* + * Make negative angles positive. + */ + if (angle < 0) { + + if (angleDiff >= twoPi) { + angle += twoPi; + } else { + angle = 0; + } + + } + + const valMin = properties.valMin; + const valMax = properties.valMax; + let value = ((angle / angleDiff) * (valMax - valMin)) + valMin; + + /* + * Clamp values into valid interval. + */ + if (value < valMin) { + value = valMin; + } else if (value > valMax) { + value = valMax; + } + + return value; + }; + + /* + * Convert touch event to value. + */ + const touchEventToValue = function(e, properties) { + const canvas = e.target; + const rect = canvas.getBoundingClientRect(); + const offsetX = rect.left; + const offsetY = rect.top; + const width = canvas.scrollWidth; + const height = canvas.scrollHeight; + const centerX = 0.5 * width; + const centerY = 0.5 * height; + const touches = e.targetTouches; + let touch = null; + + /* + * If there are touches, extract the first one. + */ + if (touches.length > 0) { + touch = touches.item(0); + } + + let x = 0.0; + let y = 0.0; + + /* + * If a touch was extracted, calculate coordinates relative to + * the element position. + */ + if (touch !== null) { + const touchX = touch.pageX; + x = touchX - offsetX; + const touchY = touch.pageY; + y = touchY - offsetY; + } + + const relX = x - centerX; + const relY = y - centerY; + const angleStart = properties.angleStart; + const angleEnd = properties.angleEnd; + const angleDiff = angleEnd - angleStart; + const twoPi = 2.0 * Math.PI; + let angle = Math.atan2(relX, -relY) - angleStart; + + /* + * Make negative angles positive. + */ + if (angle < 0) { + + if (angleDiff >= twoPi) { + angle += twoPi; + } else { + angle = 0; + } + + } + + const valMin = properties.valMin; + const valMax = properties.valMax; + let value = ((angle / angleDiff) * (valMax - valMin)) + valMin; + + /* + * Clamp values into valid interval. + */ + if (value < valMin) { + value = valMin; + } else if (value > valMax) { + value = valMax; + } + + return value; + }; + + /* + * Show input element on double click. + */ + const doubleClickListener = function(e) { + const properties = knob._properties; + const readonly = properties.readonly; + + /* + * If knob is not read-only, display input element. + */ + if (!readonly) { + e.preventDefault(); + const inputDiv = knob._inputDiv; + inputDiv.style.display = 'block'; + const inputElem = knob._input; + inputElem.focus(); + knob.redraw(); + } + + }; + + /* + * This is called when the mouse button is depressed. + */ + const mouseDownListener = function(e) { + const btn = e.buttons; + + /* + * It is a left-click. + */ + if (btn === 1) { + const properties = knob._properties; + const readonly = properties.readonly; + + /* + * If knob is not read-only, process mouse event. + */ + if (!readonly) { + e.preventDefault(); + const val = mouseEventToValue(e, properties); + knob.setValueFloating(val); + } + + knob._mousebutton = true; + } + + /* + * It is a middle click. + */ + if (btn === 4) { + const properties = knob._properties; + const readonly = properties.readonly; + + /* + * If knob is not read-only, display input element. + */ + if (!readonly) { + e.preventDefault(); + const inputDiv = knob._inputDiv; + inputDiv.style.display = 'block'; + const inputElem = knob._input; + inputElem.focus(); + knob.redraw(); + } + + } + + }; + + /* + * This is called when the mouse cursor is moved. + */ + const mouseMoveListener = function(e) { + const btn = knob._mousebutton; + + /* + * Only process event, if mouse button is depressed. + */ + if (btn) { + const properties = knob._properties; + const readonly = properties.readonly; + + /* + * If knob is not read-only, process mouse event. + */ + if (!readonly) { + e.preventDefault(); + const val = mouseEventToValue(e, properties); + knob.setValueFloating(val); + } + + } + + }; + + /* + * This is called when the mouse button is released. + */ + const mouseUpListener = function(e) { + const btn = knob._mousebutton; + + /* + * Only process event, if mouse button was depressed. + */ + if (btn) { + const properties = knob._properties; + const readonly = properties.readonly; + + /* + * If knob is not read only, process mouse event. + */ + if (!readonly) { + e.preventDefault(); + const val = mouseEventToValue(e, properties); + knob.setValue(val); + } + + } + + knob._mousebutton = false; + }; + + /* + * This is called when the drag action is canceled. + */ + const mouseCancelListener = function(e) { + const btn = knob._mousebutton; + + /* + * Abort action if mouse button was depressed. + */ + if (btn) { + knob.abort(); + knob._mousebutton = false; + } + + }; + + /* + * This is called when a user touches the element. + */ + const touchStartListener = function(e) { + const properties = knob._properties; + const readonly = properties.readonly; + + /* + * If knob is not read-only, process touch event. + */ + if (!readonly) { + const touches = e.targetTouches; + const numTouches = touches.length; + const singleTouch = (numTouches === 1); + + /* + * Only process single touches, not multi-touch + * gestures. + */ + if (singleTouch) { + knob._mousebutton = true; + + /* + * If this is the first touch, bind double tap + * interval. + */ + if (knob._touchCount === 0) { + + /* + * This is executed when the double tap + * interval times out. + */ + const f = function() { + + /* + * If control was tapped exactly + * twice, enable on-screen keyboard. + */ + if (knob._touchCount === 2) { + const properties = knob._properties; + const readonly = properties.readonly; + + /* + * If knob is not read-only, + * display input element. + */ + if (!readonly) { + e.preventDefault(); + const inputDiv = knob._inputDiv; + inputDiv.style.display = 'block'; + const inputElem = knob._input; + inputElem.focus(); + knob.redraw(); + } + + } + + knob._touchCount = 0; + }; + + let timeout = knob._timeoutDoubleTap; + window.clearTimeout(timeout); + timeout = window.setTimeout(f, 500); + knob._timeoutDoubleTap = timeout; + } + + knob._touchCount++; + const val = touchEventToValue(e, properties); + knob.setValueFloating(val); + } + + } + + }; + + /* + * This is called when a user moves a finger on the element. + */ + var touchMoveListener = function(e) { + const btn = knob._mousebutton; + + /* + * Only process event, if mouse button is depressed. + */ + if (btn) { + const properties = knob._properties; + const readonly = properties.readonly; + + /* + * If knob is not read-only, process touch event. + */ + if (!readonly) { + const touches = e.targetTouches; + const numTouches = touches.length; + const singleTouch = (numTouches === 1); + + /* + * Only process single touches, not multi-touch + * gestures. + */ + if (singleTouch) { + e.preventDefault(); + const val = touchEventToValue(e, properties); + knob.setValueFloating(val); + } + + } + + } + + }; + + /* + * This is called when a user lifts a finger off the element. + */ + const touchEndListener = function(e) { + const btn = knob._mousebutton; + + /* + * Only process event, if mouse button was depressed. + */ + if (btn) { + const properties = knob._properties; + const readonly = properties.readonly; + + /* + * If knob is not read only, process touch event. + */ + if (!readonly) { + const touches = e.targetTouches; + const numTouches = touches.length; + const noMoreTouches = (numTouches === 0); + + /* + * Only commit value after the last finger has + * been lifted off. + */ + if (noMoreTouches) { + e.preventDefault(); + knob._mousebutton = false; + knob.commit(); + } + + } + + } + + knob._mousebutton = false; + }; + + /* + * This is called when a user cancels a touch action. + */ + const touchCancelListener = function(e) { + const btn = knob._mousebutton; + + /* + * Abort action if mouse button was depressed. + */ + if (btn) { + knob.abort(); + knob._touchCount = 0; + const timeout = knob._timeoutDoubleTap; + window.clearTimeout(timeout); + } + + knob._mousebutton = false; + }; + + /* + * This is called when the size of the canvas changes. + */ + const resizeListener = function(e) { + knob.redraw(); + }; + + /* + * This is called when the mouse wheel is moved. + */ + const scrollListener = function(e) { + const readonly = knob.getProperty('readonly'); + + /* + * If knob is not read only, process mouse wheel event. + */ + if (!readonly) { + e.preventDefault(); + const delta = e.deltaY; + const direction = delta > 0 ? 1 : (delta < 0 ? -1 : 0); + let val = knob.getValue(); + val += direction; + knob.setValueFloating(val); + + /* + * Perform delayed commit. + */ + const commit = function() { + knob.commit(); + }; + + let timeout = knob._timeout; + window.clearTimeout(timeout); + timeout = window.setTimeout(commit, 250); + knob._timeout = timeout; + } + + }; + + /* + * This is called when the user presses a key on the keyboard. + */ + const keyPressListener = function(e) { + const kc = e.keyCode; + + /* + * Hide input element when user presses enter or escape. + */ + if ((kc === 13) || (kc === 27)) { + const inputDiv = knob._inputDiv; + inputDiv.style.display = 'none'; + const input = e.target; + + /* + * Only evaluate value when user pressed enter. + */ + if (kc === 13) { + const properties = knob._properties; + const value = input.value; + const stringToValue = properties.fnStringToValue; + const val = stringToValue(value); + const valid = isFinite(val); + + /* + * Check if input is a valid number. + */ + if (valid) { + knob.setValue(val); + } + + } + + input.value = ''; + } + + }; + + canvas.addEventListener('dblclick', doubleClickListener); + canvas.addEventListener('mousedown', mouseDownListener); + canvas.addEventListener('mouseleave', mouseCancelListener); + canvas.addEventListener('mousemove', mouseMoveListener); + canvas.addEventListener('mouseup', mouseUpListener); + canvas.addEventListener('resize', resizeListener); + canvas.addEventListener('touchstart', touchStartListener); + canvas.addEventListener('touchmove', touchMoveListener); + canvas.addEventListener('touchend', touchEndListener); + canvas.addEventListener('touchcancel', touchCancelListener); + canvas.addEventListener('wheel', scrollListener); + input.addEventListener('keypress', keyPressListener); + return knob; + }; + +} + +const pureknob = new PureKnob(); +