Can I use an image (.svg) or svg path(?) in Konva.Group()'s clipFunc?

408 views Asked by At

Suppose I have a map of the world.

And I'd like each continent to be an area where I could attach shapes to and drag/reshape them, while always being clipped by the continent's shape borders/limits.

Here's what I have so far:

const stage = new Konva.Stage({
  container: 'stage',
  width: window.innerWidth,
  height: window.innerHeight
});

const layer = new Konva.Layer();

const group = new Konva.Group({
  clipFunc: function (ctx) {
    ctx.arc(250, 120, 50, 0, Math.PI * 2, false);
    ctx.arc(150, 120, 60, 0, Math.PI * 2, false);
  },
});

const shape = new Konva.Rect({
  x: 150,
  y: 70,
  width: 100,
  height: 50,
  fill: "green",
  stroke: "black",
  strokeWidth: 4,
  draggable: true,
});


group.add(shape);
layer.add(group);
stage.add(layer);
body {
    margin: 0;
    padding: 0;
  }
<script src="https://cdnjs.cloudflare.com/ajax/libs/konva/8.4.0/konva.min.js"></script>


<div id="stage"></div>

My question is, how could I use the clipFunc to draw a continent's limits? Could I use and image? svg path? I can't seem to find the answer in the docs.

2

There are 2 answers

1
Vanquished Wombat On BEST ANSWER

[Edit: Added a new option 2, added demos for options 2 & 3 codepen + snippets.]

Dragging around teh country of Wombania

TLDR: Nothing totally automatic but two possible options.

Just to confirm - based on

And I'd like each continent to be an area where I could attach shapes to and drag/reshape them, while always being clipped by the continent's shape borders/limits.

I think you are asking how to limit the boundaries for dragging a shape to an 'arbitrary' region. I say arbitrary because it is a non-geometric region (not a square, circle, pentagon etc).

It would be fabulous to have a baked-in function to achieve this but sadly I am not aware that it is possible. Here's why:

Dragbounds limits: In terms of what you get 'out of the box', how Konva handles constraining drag position is via the node.dragBoundFunc(). Here is the example from the Konva docs which is straightforward.

// get drag bound function var dragBoundFunc = node.dragBoundFunc();

// create vertical drag and drop
node.dragBoundFunc(function(pos){
  // important pos - is absolute position of the node
  // you should return absolute position too
  return {
    x: this.absolutePosition().x,
    y: pos.y
  };
});

The gist of this is that we are able to use the code in the dragBoundFunc function to decide if we like the position the shape is being dragged to, or not. If not we can override that 'next' position with our own.

Ok - so that is how dragging is constrained via dragBoundFunc. You can also use the node.on('dragmove') to achieve the same effect - the code would be very similar.

Hit testing

To decide in the dragBoundFunc whether to accept the proposed position of the shape being dragged, we need to carry out 'hit testing'.

[Aside: An important consideration is that, to make a pleasing UI, we should be hit testing at the boundary of the shape that is being dragged - not where the mouse pointer or finger are positioned. Example - think of a circle being dragged with the mouse pointer at its center - we want to show the user the 'hit' UI when the perimeter of the circle goes 'out of bounds' from the perspective of the dragBoundFunc, not when the center hits that point. What this means in effect is that our logic should check the perimeter of the shape for collision with the boundary - that might be simple or more difficult, depending on the shape.]

So we know we want to hit test our dragging shape against an arbitrary, enclosing boundary (the country border).

Option #1: Konva built-in method.

[Update] On developing the demo for this option I discovered that its mainstay, getIntersection(pt), is deliberately disabled (will always return null) when used in a dragmove situation. This is by design and done for performance because the overhead for the process is so costly.

What getIntersection does is to look at a given pixel, from topmost shape down, of the shapes that might overlap the given x, y point. It stops at the first hit. To do this is draws in an off-screen canvas each shape, checks the pixel, and repeats until no shapes remain. As you can tell, quite a costly process to run in-between mousemove steps.

The proposal for this option was to check a bunch of static border points on the stage via getIntersection - if the dragging shape came up as the hit then we would know the border was being crossed.

What point do we give it to check ? So here's the rub - you would have to predefine points on your map that were on the borders. How many points? Enough so that your draggable shapes can't stray very far over the mesh of border points without the hit-test firing. Do it correctly and this would be a very efficient method of hit testing. And it's not as if the borders will be changing regularly.

I made a simple point creator here. This is the view after I created the points around Wombania.

enter image description here

** Option #2: The concept is to create an off-screen canvas the same size as the client rect of the shape being dragged and create a clone of the drag shape therein. Space around the shape would be transparent, shape itself would be colored. We now use a set of pre-defined points along the country boundary, example above. We filter the points inside the shape's clientRect - so we only have a handful to test. We then 'translate' those points to the appropriate location in the off-screen hit canvas and check the color of the pixels at those points - any color whatsoever indicates the dragging shape is 'over' the point we are testing. Any hit means we can break out of the loop and report a boundary collision.

This is optimised in the following ways: 1 - we only make the offscreen canvas once at the dragstart. 2 - we only test the minimum number of boundary points - only those falling in the bounding box of the dragging shape.

Demo here at codepen. Snippet below - best consumed full screen.

const scale = 1,
  stage = new Konva.Stage({
    container: "container",
    width: 500,
    height: 400,
    draggable: false
  }),
  layer = new Konva.Layer({
    draggable: false
  }),
  imageShape = new Konva.Image({
    x: 0,
    y: 0,
    draggable: false
  }),
  // Rect drawn to show client rect of dragging shape
  theShapeRect = new Konva.Rect({
    stroke: "silver",
    strokeWidth: 1,
    listening: false
  }),
  // small dots to show check points
  pointCircle = new Konva.Circle({
    radius: 30,
    fill: "silver",
    draggable: false
  }),
  // the three draggable shape defs - select by button
  dragShapes = {
    circle: new Konva.Circle({
      radius: 30,
      fill: "lime",
      draggable: true,
      visible: false
    }),
    rectangle: new Konva.Rect({
      width: 60,
      height: 60,
      fill: "lime",
      draggable: true,
      visible: false
    }),
    star: new Konva.Star({
      numPoints: 6,
      innerRadius: 40,
      outerRadius: 70,
      fill: "lime",
      draggable: true,
      visible: false
    })
  },
  // data for the check points.
  data = `{"pt0":{"x":85.5,"y":44.5},"pt1":{"x":76,"y":62},"pt2":{"x":60,"y":78},"pt3":{"x":47,"y":94},"pt4":{"x":33,"y":115},"pt5":{"x":26,"y":133},"pt6":{"x":17,"y":149},"pt7":{"x":27,"y":171},"pt8":{"x":45,"y":186},"pt9":{"x":69,"y":187},"pt10":{"x":87,"y":191},"pt11":{"x":104,"y":194},"pt12":{"x":123,"y":214},"pt13":{"x":124,"y":238},"pt14":{"x":120,"y":260},"pt15":{"x":94,"y":265},"pt16":{"x":92,"y":275},"pt17":{"x":113,"y":281},"pt18":{"x":130,"y":280},"pt19":{"x":148,"y":280},"pt20":{"x":156,"y":261},"pt21":{"x":169,"y":248},"pt22":{"x":188,"y":251},"pt23":{"x":201,"y":263},"pt24":{"x":207,"y":274},"pt25":{"x":195,"y":281},"pt26":{"x":181,"y":285},"pt27":{"x":183,"y":291},"pt28":{"x":194,"y":293},"pt29":{"x":222,"y":293},"pt30":{"x":242,"y":284},"pt31":{"x":245,"y":257},"pt32":{"x":247,"y":238},"pt33":{"x":263,"y":236},"pt34":{"x":278,"y":240},"pt35":{"x":293,"y":239},"pt36":{"x":305,"y":238},"pt37":{"x":315,"y":237},"pt38":{"x":333,"y":236},"pt39":{"x":337,"y":248},"pt40":{"x":324,"y":258},"pt41":{"x":303,"y":263},"pt42":{"x":314,"y":267},"pt43":{"x":326,"y":273},"pt44":{"x":347,"y":273},"pt45":{"x":364,"y":273},"pt46":{"x":378,"y":260},"pt47":{"x":401,"y":263},"pt48":{"x":422,"y":272},"pt49":{"x":429,"y":278},"pt50":{"x":414,"y":281},"pt51":{"x":400,"y":287},"pt52":{"x":411,"y":294},"pt53":{"x":434,"y":292},"pt54":{"x":462,"y":287},"pt55":{"x":478,"y":275},"pt56":{"x":474,"y":259},"pt57":{"x":466,"y":233},"pt58":{"x":470,"y":208},"pt59":{"x":483,"y":189},"pt60":{"x":484,"y":169},"pt61":{"x":494,"y":153},"pt62":{"x":496,"y":129},"pt63":{"x":489,"y":106},"pt64":{"x":472,"y":91},"pt65":{"x":458,"y":78},"pt66":{"x":443,"y":65},"pt67":{"x":428,"y":54},"pt68":{"x":412,"y":41},"pt69":{"x":394,"y":31},"pt70":{"x":369,"y":23},"pt71":{"x":346,"y":22},"pt72":{"x":323,"y":22},"pt73":{"x":300,"y":23},"pt74":{"x":278,"y":24},"pt75":{"x":265,"y":26},"pt76":{"x":251,"y":30},"pt77":{"x":235,"y":32},"pt78":{"x":220,"y":38},"pt79":{"x":203,"y":44},"pt80":{"x":189,"y":53},"pt81":{"x":174,"y":57},"pt82":{"x":163,"y":51},"pt83":{"x":148,"y":53},"pt84":{"x":128,"y":52},"pt85":{"x":100,"y":51}}`,
  // load the data into an object.
  pointsList = JSON.parse(data);

// shape is set when the shape-type button is clicked.
let theShape = undefined;

// Add shapes to the layer and layer to stage
layer.add(
  imageShape,
  dragShapes.circle, // not visible at this point
  dragShapes.rectangle, // not visible at this point
  dragShapes.star, // not visible at this point
  theShapeRect
);
stage.add(layer);

// Make the hit stage where we will do color sampling
const hitStage = new Konva.Stage({
    container: "container2",
    width: 300,
    height: 300,
    draggable: true
  }),
  hitLayer = new Konva.Layer(),
  ctx = hitLayer.getCanvas().getContext(); // Get the convas context for access to pixel data

hitStage.add(hitLayer);

// Make an HTML image variable to act as the image loader, load the image
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function () {
  imageShape.image(img); // when loaded give the image to the Konva image shape
};
img.src = "https://assets.codepen.io/255591/map_of_wombania2.svg"; // start image loading - fires onload above.

// draw a small grey dot centered on each test point
for (const [key, pt] of Object.entries(pointsList)) {
  layer.add(
    pointCircle.clone({
      name: key + " point",
      radius: 5,
      x: pt.x,
      y: pt.y
    })
  );
}

// Function to get the color data for given point on a given canvas context
function getRGBAInfo(ctx, point) {
  // get the image data for one pixel at the computed point
  const pixel = ctx.getImageData(point.x, point.y, 1, 1);
  const data = pixel.data;

  // for fun, we show the rgba value at the pixel
  const rgba =
    "pt " +
    JSON.stringify(point) +
    ` rgba(${data[0]}, ${data[1]}, ${data[2]}, ${data[3] / 255})`;
  //  console.log(rgba);
  return data;
}

// function to reset collided point colors
function clearPoints() {
  // clear the collision point colors
  const points = stage.find(".point");
  for (const point of points) {
    point.fill("silver");
  }
}

// variable to track whether we collided or not.
let hit = false;

// user clicks a shape-select button
$(".shapeButton").on("click", function () {
  setShape($(this).data("shape"));
});

// Set the active shape.
function setShape(shapeName) {
  clearPoints();

  if (theShape) {
    theShape.visible(false);
  }
  theShape = dragShapes[shapeName];

  // Somewhere in Wombania....
  theShape.position({
    x: 300,
    y: 120
  });

  // finally we see the shape !
  theShape.visible(true);

  // and set the bounding rect visualising rect
  theShapeRect.position(theShape.getClientRect());
  theShapeRect.size(theShape.getClientRect());

  // better clear any listeners on the shape just in case
  theShape.off();

  // fires once as the drag commences
  theShape.on("dragstart", function (evt) {
    // clear the hitLayer for color testing
    hitLayer.destroyChildren();

    // make a copy of the dragging shape, positioned at top-left of hit canvas
    // Note I fill shape with solid color - if you drag a Konva.Group then make a filled rect
    // the pos & size of the group.getClientRect and add that into the group after cloning.
    const clone = evt.target.clone({ fill: "red", stroke: "red" });
    clone.position({
      x: clone.width() / 2,
      y: clone.height() / 2
    });
    hitLayer.add(clone);

    // cloning copies some events so better clear them as they are not needed on the clone.
    clone.off();

    // reset the boundary point color
    clearPoints();

    // position the client rect visulaiser
    theShapeRect.position(theShape.getClientRect());
    theShapeRect.size(theShape.getClientRect());
  });

  // Will run on each drag move event
  theShape.on("dragmove", function (evt) {
    // assume no collisions - we will know by the end of the event
    hit = false;

    // position the client rect visulaiser
    theShapeRect.position(theShape.getClientRect());

    // Get the translation vector from the drag shape in the main canvas to the location
    // in the hit canvas. We use thit to translate the check points in the main canvas
    // to their positions in the hit canvas
    const translateDist = {
      x: -this.position().x + this.width() / 2,
      y: -this.position().y + this.width() / 2
    };

    // get a rect around the current pos of the draggging shape, use to check if points
    // are within this rect. If YES then process them, otherwise ignore.
    const checkRect = this.getClientRect();

    // Walk the set of check points...
    for (const [key, pt] of Object.entries(pointsList)) {
      // Is this point in the client rect of the dragging shape ?...
      if (
        checkRect.x < pt.x &&
        checkRect.y < pt.y &&
        checkRect.x + checkRect.width > pt.x &&
        checkRect.y + checkRect.height > pt.y
      ) {
        //...yes - so we pocess it

        // translate the point to its position in the hit canvas.
        let pointTranslated = {
          x: pt.x + translateDist.x,
          y: pt.y + translateDist.y
        };

        // get the color info of the point
        const colorInfo = getRGBAInfo(ctx, pointTranslated);

        // Is there any color there, anything, at all, maybe ?
        if (colorInfo[0] + colorInfo[1] + colorInfo[2] + colorInfo[3] > 0) {
          // if we find color then we have a collision!
          hit = true;

          // set the color of the collided point to visualise it
          stage.findOne("." + key).fill("black");

          // !Important: In live code we could 'break' here because it is not
          // important to know _all_ the hits. I will process them all for demo purposes.
          // break;
        }
      }
    }

    // Phew - after all that point fettling, if we got a hit then say so !
    if (hit) {
      $("#alarm").html("Boundary collision");
      evt.target.fill("red");
    } else {
      evt.target.fill("lime");
      $("#alarm").html("Still good");
    }
  });
}
body {
  margin: 10px;
  background-color: #f0f0f0;
}
.container {
  border: 1px solid black;
  display: inline-block;
}
alarm {
  color: red;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://unpkg.com/konva@8/konva.min.js"></script>
<p><span id='info'>Pick a shape, drag it around the country without hitting the edges!</span></p>
<p><span id='alarm'>.</span></p>
<p>
  <button class="shapeButton" data-shape='circle'>Circle</button>
  <button class="shapeButton" data-shape='rectangle'>Rectangle</button>
  <button class="shapeButton" data-shape='star'>Star</button>
</span></p>
<div id="container" class='container'></div> 

<div id="container2" class='container'></div>

Option #3: Alpha value checking.

The gist of this method is to have the color fill of each country have a specific alpha value in its RGBA setting. You can then check the colors at specific points on the perimeter of your dragging shape. Lets say we set the alpha for France to 250, the Channel is 249, Spain 248, Italy 247, etc. If you are dragging your shape around 'inside' France, you expect an alpha value of 250. If you see anything else under any of those perimeter points then some part of your shape has crossed the border. [In practice, the HTML canvas will add some antialiasing along the border line so you will see some values outside those that you set but these have a low impact affect and can be ignored.]

One point is that you can't test the color on the main canvas if the shape being dragged is visible - because you will be getting the fill, stroke, or antialised pixel color of the shape!

To solve this you need a second stage - this can be memory only, so not visible on the page - where you load either a copy of the main stage with the dragging shape invisible, or you load the image of the map only. Let's call this the hit-stage. Assuming you keep the position of the hit-stage in line with the main-stage, then everything will work. Based on the location of the dragging shape and its perimeter points, you check the pixel colors on the hit-canvas. If the values match the country you are expecting then no hit, but if you see a different alpha value then you hit or passed the border. Actually you don't even need to know the color for the starting country - just note the color under the mouse point when the drag commences and look out for a different alpha value under the perimeter points.

There's a working demo of the 2-stage approach at codePen here. The demo just uses a country boundary and 'elsewhere' but you would use the same technique to construct an atlas of countries with different alpha values for your needs.

enter image description here

This is the JavaScript from the codepen demo. Best seen in full screen though when I checked it after copying from codepen some of the detections on right hand side did not fire, so maybe view the codepen if you can.

const countries = [
    { name: "wombania", alpha: 252 },
    // add more countries as required
    { name: "Elsewhere", alpha: 0 }
  ],
  
  scale = 1,
  stage = new Konva.Stage({
    container: "container",
    width: 500,
    height: 400,
    draggable: false,
    scale: {
      x: scale,
      y: scale
    }
  }),
  layer = new Konva.Layer({
    draggable: false
  }),
  imageShape = new Konva.Image({
    x: 0,
    y: 0,
    draggable: false
  }),
  circle = new Konva.Circle({
    radius: 30,
    fill: "lime",
    draggable: true,
    x: 300,
    y: 120,
    scale: {
      x: scale,
      y: scale
    }
  });

let currentCountry = undefined;

const hitStage = new Konva.Stage({
    container: "container2",
    width: 500,
    height: 400,
    draggable: false
  }),
  hitLayer = new Konva.Layer(),
  hitImage = new Konva.Image(),
  ctx = hitLayer.getCanvas().getContext(); // Get the convas context for access to pixel data

layer.add(imageShape, circle);
stage.add(layer);

hitLayer.add(hitImage);
hitStage.add(hitLayer);

// Make an HTML image variable to act as the image loader, load the image
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function () {
  imageShape.image(img); // when loaded give the image to the Konva image shape
  hitImage.image(img); // and to the hit canvas
  const hitImageObj = new Image();
};
img.src = "https://assets.codepen.io/255591/map_of_wombania2.svg"; // start image loading - fires onload above.

// Will run on each drag move event
circle.on("dragmove", function () {
  // get 20 points on the perimeter to check.
  let hitCountry = currentCountry;
  for (let angle = 0; angle < 360; angle = angle + 18) {
    const angleRadians = (angle * Math.PI) / 180;
    let point = {
      x: parseInt(
        circle.position().x + Math.cos(angleRadians) * circle.radius(),
        10
      ),
      y: parseInt(
        circle.position().y + Math.sin(angleRadians) * circle.radius(),
        10
      )
    };

    // get the image data for one pixel at the computed point
    const pixel = ctx.getImageData(point.x, point.y, 1, 1);
    const data = pixel.data;

    // for fun, we show the rgba value at the pixel
    const rgba = `rgba(${data[0]}, ${data[1]}, ${data[2]}, ${data[3] / 255})`;
 //   console.log("color at (" + point.x + ", " + point.y + "):", rgba);

    // Here comes the good part.
    // We know the alpha value for the current country - any other value means
    // we crossed the border!
    let country = getCountryAtPoint(point);
    if (country && country.name !== currentCountry.name) {
      hitCountry = country;
      break; // jump out of the loop now because we know we got a hit.
    }
  }

  // After checking the points what did the hit indicator show ?
  if (hitCountry.alpha !== currentCountry.alpha) {
    circle.fill("magenta");
    $("#alarm").html("You crossed the border into " + hitCountry.name);
  } else {
    circle.fill("lime");
    $("#alarm").html("Still inside " + hitCountry.name);
  }
});

function getRGBAInfo(ctx, point) {
  // get the image data for one pixel at the computed point
  const pixel = ctx.getImageData(point.x, point.y, 1, 1);
  const data = pixel.data;

  // for fun, we show the rgba value at the pixel
  const rgba = `rgba(${data[0]}, ${data[1]}, ${data[2]}, ${data[3] / 255})`;
  return data;
}

imageShape.on("mousemove", function () {
  const point = stage.getPointerPosition();
  getRGBAInfo(ctx, point);
});

function getCountryAtPoint(point) {
  const colorInfo = getRGBAInfo(ctx, point);
  for (const country of countries) {
    if (country.alpha === colorInfo[3]) {
      $("#info2").html("Selected: " + country.name);
      return country;
    }
  }
}

imageShape.on("mousedown", function () {
  currentCountry = getCountryAtPoint(stage.getPointerPosition());
});

circle.on("mousedown", function () {
  currentCountry = getCountryAtPoint(stage.getPointerPosition());
});
body {
  margin: 10px;
  background-color: #f0f0f0;
}
.container {
  border: 1px solid black;
  display: inline-block;
}
alarm {
  color: red;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://unpkg.com/konva@8/konva.min.js"></script>
<p><span id='info'>Drag the circle around the country without hitting the edges!</span></p>
<p><span id='info2'>Selected: none</span> <span id='alarm'></span></p>
<div id="container" class='container'></div>
<div id="container2" class='container'></div>

PS. As a bonus, knowing the alpha values of the countries gives you an instant way to know which country the user clicks on. See the mousedown event.

2
Prateek Panwar On

To use an image, you can use the drawImage method of the canvas context in the clipFunc

const image = new Image();
image.src = 'image.png';

const group = new Konva.Group({
    clipFunc: function (ctx) {
        ctx.drawImage(image, 0, 0);
    },
});

To use an SVG path, you can use the clip method of the canvas context in the clipFunc

const group = new Konva.Group({
    clipFunc: function (ctx) {
        ctx.clip('M10,10 h80 v80 h-80 Z');
    },
});