0

I am trying to write a scroll-on-hover function in an HTML canvas by defining a hover variable, detecting mouse events over the designated hover area and (on doing so) adding or subtracting to this hover variable depending on which area is hovered over. This hover variable is connected to the position of a series of selection buttons which, for the purposes of this example, contain the numbers 0 to 30. When either end of this series of selection buttons is hovered over they all move up or down as if scrolled, but to make it act like a scroll you must keep the mouse moving as the canvas is only rendered on each new mousemove event.

My question is how can I trigger the event on mouseover such that if (lowerHoverBoxHitTest(x, y)) or (upperHoverBoxHitTest(x, y)) (i.e if the mouse is hovered over either of the hit boxes defined in the script below) the hover variable keeps being added to by the set increment (0.1) until the mouse leaves that area. I have tried replacing the if/else statement in the function mouseMove with a while loop (as it would seem this is logically akin to what I am asking) as so

while (lowerHoverBoxHitTest(x, y)) {
    if (hover < 750) {
      hover-=0.1;
    }
} 

while (upperHoverBoxHitTest(x, y)) {
    if (hover > 0) {
      hover+=0.1;
    }
}

but this just causes the page to crash (presumably it triggers an infinite loop?). There isn't much on Stack Overflow about this besides this but this solution is not useful if you have a lot of other things in your canvas that you don't want to scroll (unless you were to define their position absolutely which I don't want to) which I do in my full project. Any help will be appreciated.

var c=document.getElementById('game'),
  canvasX=c.offsetLeft,
  canvasY=c.offsetTop,
  ctx=c.getContext('2d');

var hover=0;

function upperHoverBoxHitTest(x, y) {
 return (x >= 0) && (x <= 350) && (y >= 0) && (y <= 50);
}

function lowerHoverBoxHitTest(x, y) {
 return (x >= 0) && (x <= 350) && (y >= 450) && (y <= 500);
}

var selectionForMenu = function(id, text, y) {
 this.id = id;
 this.text = text;
 this.y = y;
}

selectionForMenu.prototype.makeSelection = function() {
 ctx.beginPath();
 ctx.fillStyle='#A84FA5';
 ctx.fillRect(0, this.y+hover, 350, 30)
 ctx.stroke();

 ctx.font='10px Noto Sans';
 ctx.fillStyle='white';
 ctx.textAlign='left';
 ctx.fillText(this.text, 10, this.y+hover+19);
}

var Paint = function(element) {
 this.element = element;
 this.shapes = [];
}

Paint.prototype.addShape = function(shape) {
 this.shapes.push(shape);
}

Paint.prototype.render = function() {
 ctx.clearRect(0, 0, this.element.width, this.element.height);

  for (var i=0; i<this.shapes.length; i++) {
  this.shapes[i].makeSelection();
 }
}

var paint = new Paint(c);
for (i=0; i<30; i++) {
 paint.addShape(new selectionForMenu(i+1, i, i*30));
}

paint.render();

function mouseMove(event) {
 var x = event.x - canvasX;
 var y = event.y - canvasY;
  paint.render();

 if (lowerHoverBoxHitTest(x, y)) {
    hover+=1;
  } else if (upperHoverBoxHitTest(x, y)) {
    hover-=1;
  }
}

c.addEventListener('mousemove', mouseMove);
canvas {
  z-index: -1;
  margin: 1em auto;
  border: 1px solid black;
  display: block;
  background: #9F3A9B;
}
<!doctype html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>uTalk Demo</title>
 <link rel='stylesheet' type='text/css' href='wordpractice.css' media='screen'>
</head>
<body>
 <canvas id="game" width = "350" height = "500"></canvas>
</body>
</html>
Community
  • 1
  • 1
Sadie LaBounty
  • 379
  • 1
  • 5
  • 23

1 Answers1

1

Animation via animation loops.

You need to have an animation loop that will increment/decrement the value if the conditions are met. This loop can be part of another if you have one (which is better than adding an animation loop for each animated object) or as its own function.

The animation loop does all the rendering, and only if needed (no point rendering something that is already rendered).

Demo

Demo is a copy of the OP's code with modifications to animate the scrolling and give a little user feed back. Though not complete as a scrolling selection box, it will need some tweaking to be useful.

var c = document.getElementById('game'),
canvasX = c.offsetLeft,
canvasY = c.offsetTop,
ctx = c.getContext('2d');

var hover = 0;
const overTypes = {
    lower : 1,
    raise : 2,
    none : 0,
}
var overBox = 0;
var overDist = 0;
const maxSpeed = 4;
const shapeSize = 30;
const hoverScrollSize = 50;
const gradUp = ctx.createLinearGradient(0, 0, 0, hoverScrollSize);
const gradDown = ctx.createLinearGradient(0, ctx.canvas.height - hoverScrollSize, 0, ctx.canvas.height);
gradUp.addColorStop(0, `rgba(${0xA8},${0x4F},${0xB5},1)`);
gradUp.addColorStop(1, `rgba(${0xA8},${0x4F},${0xB5},0)`);
gradDown.addColorStop(1, `rgba(${0xB8},${0x5F},${0xB5},1)`);
gradDown.addColorStop(0, `rgba(${0xB8},${0x5F},${0xB5},0)`);

c.addEventListener('mousemove', mouseMove)
c.addEventListener('mouseout', () => {
    overBox = overTypes.none
}); // stop scroll when mouse out of canvas
// start the first frame
requestAnimationFrame(() => {
    paint.render(); // paint first frame
    requestAnimationFrame(mainLoop); // start main loop
});
function mainLoop() {
    if (overBox !== overTypes.none) {
        hover += overDist / hoverScrollSize * (overBox === overTypes.lower ? maxSpeed : -maxSpeed);
        var bottom =  - (paint.shapes.length - ctx.canvas.height / shapeSize) * shapeSize;

        hover = hover > 0 ? 0 : hover < bottom ? bottom : hover;
        paint.render();
    }
    requestAnimationFrame(mainLoop); // wait for next animation frame
}

function mouseMove(event) {
    var x = event.clientX - canvasX;
    var y = event.clientY - canvasY;
    if (lowerHoverBoxHitTest(x, y)) {
        overBox = overTypes.lower;
    } else if (upperHoverBoxHitTest(x, y)) {
        overBox = overTypes.raise;
    } else {
        overBox = overTypes.none;
    }
}

function upperHoverBoxHitTest(x, y) {
    overDist = hoverScrollSize - y;
    return (x >= 0) && (x <= 350) && (y >= 0) && (y <= hoverScrollSize);
}

function lowerHoverBoxHitTest(x, y) {
    overDist = y - (ctx.canvas.height - hoverScrollSize);
    return (x >= 0) && (x <= 350) && (y >= ctx.canvas.height - hoverScrollSize) && (y <= ctx.canvas.height);
}

var selectionForMenu = function (id, text, y) {
    this.id = id;
    this.text = text;
    this.y = y;
}

selectionForMenu.prototype.makeSelection = function () {
    ctx.beginPath();
    ctx.fillStyle = '#A84FA5';
    ctx.fillRect(0, this.y + hover, 350, shapeSize)
    ctx.stroke();

    ctx.font = '10px Noto Sans';
    ctx.fillStyle = 'white';
    ctx.textAlign = 'left';
    ctx.fillText(this.text, 10, this.y + hover + 19);
}

var Paint = function (element) {
    this.element = element;
    this.shapes = [];
}

Paint.prototype.addShape = function (shape) {
    this.shapes.push(shape);
}

Paint.prototype.render = function () {
    ctx.clearRect(0, 0, this.element.width, this.element.height);

    for (var i = 0; i < this.shapes.length; i++) {
        this.shapes[i].makeSelection();
    }
    if (overBox !== overTypes.none) {
        ctx.globalAlpha = 0.4 * (overDist / 50);
        ctx.globalCompositeOperation = "lighter";
        if (overBox === overTypes.raise) {
            ctx.fillStyle = gradUp;
            ctx.fillRect(0, 0, ctx.canvas.width, hoverScrollSize);
        } else if (overBox === overTypes.lower) {
            ctx.fillStyle = gradDown;
            ctx.fillRect(0, ctx.canvas.height - hoverScrollSize, ctx.canvas.width, hoverScrollSize);
        }
        ctx.globalCompositeOperation = "source-over";
        ctx.globalAlpha = 1;
    }
}

var paint = new Paint(c);
for (i = 0; i < 30; i++) {
    paint.addShape(new selectionForMenu(i + 1, i, i * 30));
}

paint.render();
canvas {
  z-index: -1;
  margin: 1em auto;
  border: 1px solid black;
  display: block;
  background: #9F3A9B;
}
<!doctype html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>uTalk Demo</title>
 <link rel='stylesheet' type='text/css' href='wordpractice.css' media='screen'>
</head>
<body>
 <canvas id="game" width = "350" height = "150"></canvas>
</body>
</html>
Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • Thanks, this is exactly what I was looking for. I've almost got it working but for some reason the hover area is limited to the top of the screen in [my expanded canvas](https://jsfiddle.net/rncbccvj/). To clarify, I would like to have two small areas (350x50px) at the top and bottom of the menu that trigger the respective lower or raise with the `overDist` speed gradient you implemented. However, at the moment these two boxes seem to be right next to each other, or rather the `lowerHoverBox` is not doing anything. – Sadie LaBounty May 11 '17 at 10:28
  • @JonathanConnell Just change the coordinates. I just compacted the list so it would fit the snippet window, ideally you would have the size as variables so you can make it any size. – Blindman67 May 11 '17 at 14:13
  • My problem is that the higher `hoverBox` seems to trigger both `lower` and `raise` events depending where you are in it and the lower box triggers a very fast scroll. I've tried just changing the coordinates in the hitTest functions but it doesn't seem to have worked. – Sadie LaBounty May 11 '17 at 15:16
  • 1
    @JonathanConnell I have updated the code to use the size of the canvas to set the lower scroll area. Your problem is with the mouse if you are scrolling the page to get at the bottom scroll area. I have changed it (mouse event) to use the client coordinates – Blindman67 May 11 '17 at 17:05
  • I'm stuck on a related question with a bounty [here](https://stackoverflow.com/questions/45065743/javascript-html-canvas-select-and-getanimationframe-compatibility) if you wouldn't mind taking a look. – Sadie LaBounty Jul 22 '17 at 09:17
  • The problem seems to be that, wherever I put requestAnimationFrame, it is only called on each event that triggers a render (rather than occurring every frame), despite calling render within every frame in the mainLoop. Where should I put requestAnimationFrame such that it renders independently of the other eventListener's? – Sadie LaBounty Jul 24 '17 at 16:24
  • @JonathanConnell ?? for this answer... To prevent unneeded renders (as they chew power, that cost clients money and taxes batteries) I usually check a flag that indicates the need to render the content. In this answer I use the `overBox` semaphore to check if there is a need to render. You can remove that if statement from the function `mainLoop`. If you are asking about your own render code. Look at this answer's `mainLoop` and you will see that requestAnimationFrame needs to be called for each frame. Calling it once will only render one frame. – Blindman67 Jul 24 '17 at 17:25