Адаптивный Canvas и hover на объекте

135
19 января 2021, 11:00

Есть картинка... пробую сделать hover... Логика такая:

  1. После загрузки изображения, с помощью вспомогательного холста получаю массив пикселей картинки (ее левой части)
  2. При движении мыши по холсту, определяю ее нахождение над картинкой
  3. После этого, на основании массива пикселей (п.1) определяю является ли прозрачным пиксель под мышкой - если нет, рисую hover (правая часть картинки)

Вроде бы получилось..., код ниже.

Однако, проблема возникла при попытке сделать холст адаптивными, к размеру экрана (и соответственно картинки)... моя реализация hover'a перестает работать... За масштаб у меня отвечают переменные scale_X и scale_Y, в коде ниже определение масштаба закомментировано.

Кто может помочь в решении проблемы?

const WIDTH = 800; 
const HEIGHT = 374; 
let scale_X = 1; // масштаб 
let scale_Y = 1; // масштаб 
let ctxStyles; 
let hover; 
const canvas = document.getElementById('canvas'); 
//const help = document.getElementById('helper'); 
const help = document.createElement('canvas'); 
const ctx = canvas.getContext('2d'); 
const helper = help.getContext('2d'); 
 
const real_width = document.documentElement.clientWidth; 
const my_width = (real_width / 100) * 70; 
const my_height = my_width / 2.14; 
//scale_X = my_width / WIDTH;  
 //scale_Y = my_height / HEIGHT; 
canvas.width = my_width; 
canvas.height = my_height; 
help.width = my_width; 
help.height = my_height; 
 
ctxStyles = canvas.getBoundingClientRect(); 
canvas.addEventListener('mousemove', handlerMousemove); 
 
const img = new Image(); 
//img.src = 'https://i.stack.imgur.com/R5QJI.gif'; 
img.crossOrigin = "anonymous"; 
img.src = 'https://i.ibb.co/pfnXvqk/barraks-4-2.gif'; 
img.onload = function() { 
  img.coords = { 
    x: 40, 
    y: 30 
  }; 
  getPixArr(img); 
  drawImgOnCanvas(img); 
}; 
 
function drawImgOnCanvas(img) { 
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 
  const arr = [ 
    img, 
    0, 
    0, 
    img.width / 2, 
    img.height, 
    img.coords.x * scale_X, 
    img.coords.y * scale_Y, 
    (img.width / 2) * scale_X, 
    img.height * scale_Y 
  ]; 
  ctx.drawImage(...arr); 
  // отрисовывает 2-ю часть картинки, на которой нарисован контур 
  if (hover) { 
    arr[1] = img.width / 2; 
    arr[3] = img.width; 
    arr[5] = img.coords.x * scale_X - 1; 
    arr[7] = img.width * scale_X; 
    ctx.drawImage(...arr); 
  } 
} 
 
function handlerMousemove(event) { 
  const mouseX = event.clientX - ctxStyles.left; 
  const mouseY = event.clientY - ctxStyles.top; 
  hover = false; 
  if (check_Mouse_On_Img(mouseX, mouseY, img)) { 
    const notTransparent = check_not_transparent_pixel(mouseX, mouseY, img); 
    if (notTransparent) { 
      hover = true; 
      drawImgOnCanvas(img); 
    } else { 
      drawImgOnCanvas(img); 
    } 
  } 
} 
 
function getPixArr(img) { 
  const ctx = helper; 
  const imgWidth = img.width; 
  const imgHeight = img.height; 
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 
  ctx.drawImage( 
    img, 
    0, 
    0, 
    imgWidth / 2, 
    imgHeight, 
    0, 
    0, 
    (imgWidth / 2) * scale_X, 
    imgHeight * scale_Y 
  ); 
  const pixArr = ctx.getImageData( 
    0, 
    0, 
    (imgWidth / 2) * scale_X, 
    imgHeight * scale_Y 
  ); 
  img.pixArr = pixArr; 
} 
 
function check_Mouse_On_Img(mouseX, mouseY, img) { 
  // const ctx = helper 
  const topX = img.coords.x * scale_X; 
  const topY = img.coords.y * scale_Y; 
  const leftX = topX; 
  const leftY = topY + img.pixArr.height; 
  const rigthX = topX + img.pixArr.width; 
  const rigthY = topY; 
  const bottomX = rigthX; 
  const bottomY = leftY; 
  ctx.beginPath(); 
  // ctx.strokeStyle = 'red'; 
  ctx.strokeStyle = "transparent"; 
  ctx.moveTo(leftX, leftY); 
  ctx.lineTo(topX, topY); 
  ctx.lineTo(rigthX, rigthY); 
  ctx.lineTo(bottomX, bottomY); 
  ctx.lineTo(leftX, leftY); 
  ctx.stroke(); 
  ctx.closePath(); 
  return ctx.isPointInPath(mouseX, mouseY); 
} 
 
//определяет прозрачный ли пиксель 
function check_not_transparent_pixel(clientX, clientY, img) { 
  const imgX = img.coords.x; 
  const imgY = img.coords.y; 
  // const imgX = img.coords.x * scale_X; 
  // const imgY = img.coords.y * scale_Y; 
  const mouseX = clientX - imgX; //т.к. массив пикселей построен из начальных координат 0:0, делаю сдвиг 
  const mouseY = clientY - imgY; 
  const pixArr = img.pixArr; 
  const index = Math.floor(get_Pix_Index(mouseX, mouseY, pixArr)); 
  let alpha = pixArr.data[index + 3]; //прозрачность 
  if (index > pixArr.data.length) { 
    return false; 
  } 
  if (alpha > 0) { 
    return true; 
  } 
  return false; 
 
  //определяет индекс пикселя в массиве пикселей 
  function get_Pix_Index(mouseX, mouseY, pixArr) { 
    let pixel; 
    if (mouseX == 0 && mouseY > 0) { 
      pixel = (pixArr.width * scale_X) * mouseY + 1; 
    } else if (mouseY == 0 && mouseX > 0) { 
      pixel = mouseX; 
    } else { 
      pixel = mouseY * (pixArr.width* scale_X) + mouseX - 1; 
    } 
    let index = pixel * 4; 
    return index; 
  } 
} 
 
// просто для тестов 
canvas.addEventListener('click', event => { 
  const mouseX = event.clientX - ctxStyles.left; 
  const mouseY = event.clientY - ctxStyles.top; 
  console.log(mouseX, mouseY); 
  ctx.beginPath(); 
  ctx.arc(mouseX, mouseY, 2, 0, 2 * Math.PI); 
  ctx.fill(); 
  ctx.closePath(); 
  ctx.putImageData(img.pixArr, mouseX, mouseY); 
});
body{ 
  height: 100vh; 
  width: 100vw; 
  overflow: hidden; 
  display: flex; 
  flex-direction: column; 
  justify-content: center; 
  align-items: center; 
} 
canvas{ 
  border: 1px solid; 
}
<canvas id="canvas" ></canvas> 
  <canvas id="helper" style="position: fixed; left: -300%"></canvas>

П.С... и дополнительный вопрос: прозрачные пиксели картинки определяются как не прозрачные, если вспомогательный холст спрятать с помощью атрибута hidden или display: none - почему?

Answer 1

Это старый способ, я называю его «пикинг буфер», суть в дополнительной картинке, в которой все объекты покрашены в уникальные цвета.

При старте приложения я сделал дубликаты спрайтов, заменив в них все не полностью прозрачные пиксели на цвет уникальный для каждого спрайта (цвет-идентификатор) и рисую их параллельно с основной канвой на скрытой канве, с которой при перемещении мыши беру цвет и определяю что под мышкой.

В примере работает наведение и перетаскивание

let imgCtx = scene.getContext("2d"); 
let pickCtx = pick.getContext("2d"); 
// это важно для того чтобы сглаживанием не изменялось значение идентификатора 
pickCtx.imageSmoothingEnabled = false; 
let index = {}; 
let objects = Array(22).fill(0).map((e, i) => img(i)); 
let bounds = scene.getBoundingClientRect(); 
let hover, drag, x, y, sx, sy; 
 
scene.addEventListener("mousemove", e => { 
    drag ? handleDrag(e) : handleHover(e); 
    draw(); 
}); 
 
scene.addEventListener("mousedown", e => { 
    if (hover) { 
        drag = hover; 
        x = drag.x - e.pageX + bounds.left; 
        y = drag.y - e.pageY + bounds.top; 
        objects.splice(objects.indexOf(drag), 1); 
        objects.push(drag) 
        draw(); 
    } 
}); 
 
scene.addEventListener("mouseup", e => drag = false); 
 
draw(); 
 
function handleDrag(e) { 
    drag.x = x + e.pageX - bounds.left 
    drag.y = y + e.pageY - bounds.top  
} 
 
function handleHover(e) { 
    if (hover)  
        hover.active = false; 
    let id = pickCtx.getImageData( 
        e.pageX - bounds.left, 
        e.pageY - bounds.top, 
        1, 1 
    ).data; 
    hover = id[3] > 200 ? index[`${id[0]}-${id[1]}-${id[2]}`] : null; 
    scene.style.cursor = hover ? "pointer" : "default"; 
    if (hover)  
        hover.active = true; 
} 
 
function draw() { 
    pickCtx.clearRect(0, 0, scene.width, scene.height); 
    imgCtx.clearRect(0, 0, scene.width, scene.height); 
    objects.forEach(img => { 
        imgCtx.drawImage(img.img, img.x, img.y); 
        pickCtx.drawImage(img.pick, img.x, img.y); 
    }); 
    hover && imgCtx.drawImage(hover.hover, hover.x, hover.y); 
} 
 
function rnd(){ 
  return Math.round(Math.random()*255) 
} 
 
function img() {     
    let id; 
    while (!id || index[id.join('-')]) 
      id = [rnd(), rnd(), rnd()] 
       
    let size = 100;   
    let img = document.createElement("canvas"); 
    img.width = img.height = size; 
    let ctx = img.getContext("2d"); 
    var grd = ctx.createLinearGradient( 
        size*0.1, Math.random() * size, 
        size*0.9, Math.random() * size 
    ); 
    grd.addColorStop(0, `hsl(${Math.random() * 255},55%,55%)`); 
    grd.addColorStop(1, `hsl(${Math.random() * 255},55%,55%)`); 
    ctx.fillStyle = grd; 
    ctx.translate(size / 2, size / 2); 
    ctx.rotate(Math.random() * 6); 
    ctx.translate(-size / 2, -size / 2); 
    let s = 20 + Math.random() * 40; 
    ctx.fillRect((size - s) / 2, (size - s) / 2, s, s); 
    ctx.fillRect((size - s) / 4, (size - s) / 4, s, s); 
    return index[id.join('-')] = { 
        x: Math.random() * (scene.width - img.width), 
        y: Math.random() * (scene.height - img.height), 
        id: id, 
        img: img, 
        pick: createPickImage(img, id), 
        hover: ImageSDF(img) 
    }; 
} 
 
// создает изображения для рисования в пикинг буфере  
// красит все не полностью прозрачные пиксели в цвет-идентификатор 
function createPickImage(img, pickColor) { 
    let pick = document.createElement("canvas"); 
    let w = pick.width = img.width; 
    let h = pick.height = img.height; 
    let ctx = pick.getContext("2d"); 
    ctx.drawImage(img, 0, 0); 
    var img = ctx.getImageData(0, 0, w, h); 
    for (var x = 0; x < w; x++) { 
        for (var y = 0; y < h; y++) { 
            let o = (y * w + x) * 4; 
            // если прозрачность не 0 - красим пиксель в цвет-идентификатор 
            if (img.data[o + 3]) { 
                img.data[o + 0] = pickColor[0]; 
                img.data[o + 1] = pickColor[1]; 
                img.data[o + 2] = pickColor[2]; 
                // рисование полупрозрачных пикселей в пикинг буфер - источник ошибок 
                // пусть все не полностью прозрачные пиксели будут польностью непрозрачные 
                img.data[o + 3] = 255;  
            } 
        } 
    } 
    ctx.putImageData(img, 0, 0); 
    return pick; 
} 
 
// эта функция создает signed distance field до изображения на входе 
// визуально это градиент, его мы будем использовать для обводки 
function ImageSDF(image) { 
    let INF = 1e20; 
    let radius = 3; 
    let cutoff = 0.1; 
    let canvas = document.createElement('canvas'); 
    let width = canvas.width = image.width; 
    let height = canvas.height = image.height; 
    let ctx = canvas.getContext('2d'); 
 
    // temporary arrays for the distance transform 
    let gridOuter = new Float64Array(width * height); 
    let gridInner = new Float64Array(width * height); 
    let f = new Float64Array(height); 
    let d = new Float64Array(width); 
    let z = new Float64Array(width + 1); 
    let v = new Int16Array(width); 
         
    ctx.clearRect(0, 0, width, height); 
    ctx.drawImage(image, 0, 0, width, height); 
 
    var imgData = ctx.getImageData(0, 0, width, height); 
 
    for (i = 0; i < width * height; i++) { 
        imgData.data[i * 4 + 0] = imgData.data[i * 4 + 3]; 
        imgData.data[i * 4 + 1] = imgData.data[i * 4 + 3]; 
        imgData.data[i * 4 + 2] = imgData.data[i * 4 + 3]; 
    } 
     
    for (var i = 0; i < width * height; i++) { 
        var a = imgData.data[i * 4 + 1] / 255; // green channe value 
        gridOuter[i] = a === 1 ? 0 : a === 0 ? INF : Math.pow(Math.max(0, 0.5 - a), 2); 
        gridInner[i] = a === 1 ? INF : a === 0 ? 0 : Math.pow(Math.max(0, a - 0.5), 2); 
    } 
 
    edt(gridOuter, width, height, f, d, v, z); 
    edt(gridInner, width, height, f, d, v, z); 
 
    for (i = 0; i < width * height; i++) { 
        var dd = gridOuter[i] - gridInner[i]; 
        let v = Math.round(255 - 255 * (dd / radius + cutoff)); 
        v = Math.max(0, Math.min(255, v)); 
        imgData.data[i * 4 + 0] = v>10 && v<245 ? 255 :0; 
        imgData.data[i * 4 + 3] = v>10 && v<245 ? 255 :0; 
    } 
    ctx.putImageData(imgData, 0, 0); 
    return canvas; 
} 
 
// 2D Euclidean distance transform by Felzenszwalb & Huttenlocher https://cs.brown.edu/~pff/dt/ 
function edt(data, width, height, f, d, v, z) { 
    for (var x = 0; x < width; x++) { 
        for (var y = 0; y < height; y++)  
            f[y] = data[y * width + x]; 
        edt1d(f, d, v, z, height); 
        for (y = 0; y < height; y++)  
            data[y * width + x] = d[y]; 
    } 
    for (y = 0; y < height; y++) { 
        for (x = 0; x < width; x++)  
            f[x] = data[y * width + x]; 
        edt1d(f, d, v, z, width); 
        for (x = 0; x < width; x++)  
            data[y * width + x] = Math.sqrt(d[x]); 
    } 
} 
 
// 1D squared distance transform 
function edt1d(f, d, v, z, n) { 
    let INF = 1e20; 
    v[0] = 0; 
    z[0] = -INF; 
    z[1] = +INF; 
 
    for (var q = 1, k = 0; q < n; q++) { 
        var s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); 
        while (s <= z[k]) { 
            k--; 
            s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); 
        } 
        k++; 
        v[k] = q; 
        z[k] = s; 
        z[k + 1] = +INF; 
    } 
 
    for (q = 0, k = 0; q < n; q++) { 
        while (z[k + 1] < q) k++; 
        d[q] = (q - v[k]) * (q - v[k]) + f[v[k]]; 
    } 
}
<canvas id="scene" width="635" height="175" style="border: 1px solid"></canvas> 
<canvas id="pick"  width="635" height="175" style="display:none"></canvas>

PS: добавил программную обводку непрозрачной области спрайта при помощи signed distance field, зашитого в текстуру

UPD: сильно уменьшил вероятность ошибок и расширил возможное количество объектов, за счет использования 3х каналов цвета для пикинга

UPD2: усложнил форму элементов для наглядности

связанный ответ: https://ru.stackoverflow.com/a/962780/188366

Answer 2

Решил проблему... как я и предположил, в комментариях, проблема была в определении индекса пикселя. Однако, оказалось, что округлять необходимо не полученный индекс, а координаты мыши

const WIDTH = 800; 
const HEIGHT = 374; 
let scale_X = 1; 
let scale_Y = 1; 
let ctxStyles; 
let hover; 
const canvas = document.getElementById('canvas'); 
// const help = document.getElementById('helper'); 
const help = document.createElement('canvas'); 
const ctx = canvas.getContext('2d'); 
const helper = help.getContext('2d'); 
 
const real_width = document.documentElement.clientWidth; 
const my_width = (real_width / 100) * 70; 
const my_height = my_width / 2.14; 
scale_X = my_width / WIDTH; 
scale_Y = my_height / HEIGHT; 
canvas.width = my_width; 
canvas.height = my_height; 
help.width = my_width; 
help.height = my_height; 
ctxStyles = canvas.getBoundingClientRect(); 
canvas.addEventListener('mousemove', handlerMousemove); 
 
const img = new Image(); 
img.crossOrigin = "anonymous"; 
img.src = 'https://i.ibb.co/pfnXvqk/barraks-4-2.gif'; 
img.onload = function() { 
 
  img.coords = { 
x: 40, 
y: 30 
  }; 
  getPixArr(img); 
  drawImgOnCanvas(img); 
}; 
 
function drawImgOnCanvas(img) { 
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 
  const arr = [ 
img, 
0, 
0, 
img.width / 2, 
img.height, 
img.coords.x * scale_X, 
img.coords.y * scale_Y, 
(img.width / 2) * scale_X, 
img.height * scale_Y 
  ]; 
  ctx.drawImage(...arr); 
  // отрисовывает 2-ю часть картинки, на которой нарисован контур 
  if (hover) { 
arr[1] = img.width / 2; 
arr[3] = img.width; 
arr[5] = img.coords.x * scale_X - 1; 
arr[7] = img.width * scale_X; 
ctx.drawImage(...arr); 
  } 
} 
 
function handlerMousemove(event) { 
  const mouseX = event.clientX - ctxStyles.left; 
  const mouseY = event.clientY - ctxStyles.top; 
  hover = false; 
  if (check_Mouse_On_Img(mouseX, mouseY, img)) { 
const notTransparent = check_not_transparent_pixel(mouseX, mouseY, img); 
if (notTransparent) { 
  hover = true; 
  drawImgOnCanvas(img); 
} else { 
  drawImgOnCanvas(img); 
} 
  } 
} 
 
function getPixArr(img) { 
  const ctx = helper; 
  const imgWidth = img.width; 
  const imgHeight = img.height; 
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 
  ctx.drawImage( 
img, 
0, 
0, 
imgWidth / 2, 
imgHeight, 
0, 
0, 
(imgWidth / 2) * scale_X, 
imgHeight * scale_Y 
  ); 
  const pixArr = ctx.getImageData( 
0, 
0, 
(imgWidth / 2) * scale_X, 
imgHeight * scale_Y 
  ); 
  img.pixArr = pixArr; 
} 
 
function check_Mouse_On_Img(mouseX, mouseY, img) { 
  // const ctx = helper 
  const topX = img.coords.x * scale_X; 
  const topY = img.coords.y * scale_Y; 
  const leftX = topX; 
  const leftY = topY + (img.pixArr.height); 
  const rigthX = topX + (img.pixArr.width); 
  const rigthY = topY; 
  const bottomX = rigthX; 
  const bottomY = leftY; 
  ctx.beginPath(); 
  ctx.strokeStyle = 'red'; 
  // ctx.strokeStyle = "transparent"; 
  ctx.moveTo(leftX, leftY); 
  ctx.lineTo(topX, topY); 
  ctx.lineTo(rigthX, rigthY); 
  ctx.lineTo(bottomX, bottomY); 
  ctx.lineTo(leftX, leftY); 
  ctx.stroke(); 
  ctx.closePath(); 
  return ctx.isPointInPath(mouseX, mouseY); 
} 
 
//определяет прозрачный ли пиксель 
function check_not_transparent_pixel(clientX, clientY, img) { 
  const imgX = img.coords.x * scale_X; 
  const imgY = img.coords.y * scale_Y; 
  const mouseX = Math.floor(clientX - imgX); //т.к. массив пикселей построен из начальных координат 0:0, делаю сдвиг 
  const mouseY = Math.floor(clientY - imgY); 
  const pixArr = img.pixArr; 
  const index = get_Pix_Index(mouseX, mouseY, pixArr); 
  let alpha = pixArr.data[index + 3]; //прозрачность 
  if (index > pixArr.data.length) { 
return false; 
  } 
  if (alpha > 0) { 
return true; 
  } 
  return false; 
 
  //определяет индекс пикселя в массиве пикселей 
  function get_Pix_Index(mouseX, mouseY, pixArr) { 
let pixel; 
if (mouseX == 0 && mouseY > 0) { 
  pixel = pixArr.width * mouseY + 1; 
} else if (mouseY == 0 && mouseX > 0) { 
  pixel = mouseX; 
} else { 
  pixel = mouseY * pixArr.width + mouseX - 1; 
} 
let index =pixel * 4; 
return index; 
  } 
} 
// просто для тестов 
canvas.addEventListener('click', event => { 
  const mouseX = event.clientX - ctxStyles.left; 
  const mouseY = event.clientY - ctxStyles.top; 
  console.log(mouseX, mouseY); 
  ctx.beginPath(); 
  ctx.arc(mouseX, mouseY, 2, 0, 2 * Math.PI); 
  ctx.fill(); 
  ctx.closePath(); 
  ctx.putImageData(img.pixArr, mouseX, mouseY); 
});
body{ 
  height: 100vh; 
  width: 100vw; 
  display: flex; 
  flex-direction: column; 
  justify-content: center; 
  align-items: center; 
} 
canvas{ 
  border: 1px solid; 
}
<canvas id="canvas" ></canvas> 
  

READ ALSO
Qt5: Запрос представления в QTableView

Qt5: Запрос представления в QTableView

Есть некоторые представленияНужно вывести их в QTableView, а так же запрос всех представления существующих в БД, результат это

106
c++: неопределённый тип при наследовании

c++: неопределённый тип при наследовании

Подскажите, как разрешить следующую ситуацию - у меня есть базовый контейнер от которого наследуется 2 различных:

105
Работа с сетью в qt

Работа с сетью в qt

Например в консольных программах на языке си или c++ я могу использовать работу с сетью постепенноНу тоесть

123
Как записать дамп при краше

Как записать дамп при краше

Выпустили мы программу в продажу,у кого-то крашит у кого-то нетУ разработчика - нет,но у некоторых юзеров - да

111