3

I'm working with Matter.JS for an interactive header graphic on a website. I have some basic letter paths in SVG, which get loaded, converted to shapes and bodies and placed in the world. They get initial colors and properties and they animate fine.

The problem is, I wanted the bodies to be interactive, in that when a user clicks one of the bodies, it can change. I used a MouseConstraint for this and it kind of works, but only for certain properties; setting a fillStyle (color) does only work for one (!) of the bodies (the letter "I") and can't figure out why.

I have set up an example and I'm hoping it's a simple thing to figure out for somebody who knows their stuff. I'm only working with MJS for the first time.

Matter.Common._seed = Math.random().toString().slice(2,10); // random 8 digit number
Matter.use(MatterAttractors);

const engine = Matter.Engine.create();
const currentWidth = document.querySelector('#canvas').offsetWidth * 2;

// letter initial coordinates
const offsets = {
  'B':  [currentWidth/100*20, currentWidth/100*5],
  'I':  [currentWidth/100*50, currentWidth/100*5],
  'G':  [currentWidth/100*70, currentWidth/100*5],
  'B2': [currentWidth/100*20, currentWidth/100*50],
  'A':  [currentWidth/100*40, currentWidth/100*50],
  'N':  [currentWidth/100*60, currentWidth/100*50],
  'D':  [currentWidth/100*80, currentWidth/100*50]
};

var render = Matter.Render.create({
  element: document.querySelector('#canvas'),
  engine: engine,
  options: {
    width: currentWidth, 
    height: currentWidth,
    background: '#222',
    wireframes: false,
    showAngleIndicator: false
  }
});

var select = function (root, selector) {
  return Array.prototype.slice.call(root.querySelectorAll(selector));
};

var loadSvg = function (url) {
  return fetch(url)
    .then(function(response) { return response.text(); })
    .then(function(raw) { return (new window.DOMParser()).parseFromString(raw, 'image/svg+xml'); });
};

// add SVG letters
document.querySelectorAll('#letters path').forEach(function (ele, i) {
  const color = ele.getAttribute('fill');
  const id = ele.getAttribute('id').replace('letter-', '');
  const vertexSets = Matter.Svg.pathToVertices(ele, 30);

  const offsetX = offsets[id][0];
  const offsetY = offsets[id][1];

  var letter = Matter.Bodies.fromVertices(offsetX, offsetY, vertexSets, {
    render: {
      fillStyle: color,
      strokeStyle: color,
      lineWidth: 1,
      opacity: 1
    },
    restitution: 0.7,
    label: 'Letter-' + id
  });

  Matter.Body.rotate(letter, Matter.Common.random(-0.3, 0.3));
  Matter.Body.scale(letter, 1.0, 1.0);
  Matter.Body.setMass(letter, 5.01);

  Matter.Composite.add(engine.world, letter, true);
});

// walls
const wallstyle = { fillStyle: '#0f0' };
var ground = Matter.Bodies.rectangle(currentWidth/2, currentWidth, currentWidth, 10, { isStatic: true, render: wallstyle });
var wallLeft = Matter.Bodies.rectangle(0, currentWidth/2, 10, currentWidth, { isStatic: true, render: wallstyle });
var wallRight = Matter.Bodies.rectangle(currentWidth, currentWidth/2, 10, currentWidth, { isStatic: true, render: wallstyle });

Matter.Composite.add(engine.world, [wallLeft, wallRight, ground]); // [boxA, boxB, ground]

Matter.Render.run(render);

var runner = Matter.Runner.create();
Matter.Events.on(runner, "tick", event => {}); // nothing as of right now
Matter.Runner.run(runner, engine);

// mouse events
const mouseConstraint = Matter.MouseConstraint.create(
  engine, 
  Matter.Mouse.create(render.canvas), 
  {}
);

Matter.Events.on(mouseConstraint, "mousedown", event => {
  const target = event.source.body;
  if (target) {
    // why is the fillStyle only working for the letter "I"?
    target.render.fillStyle = Matter.Common.choose(['#f19648', '#f5d259', '#f55a3c']);

    // applying force works for some reason
    const dir = Matter.Common.random(-0.3, 0.3);
    Matter.Body.applyForce(target, { x: target.position.x, y: target.position.y }, { x: dir, y: -0.2 });
  }
});
html {
  background: black;
  color: #fff;
}

#canvas {
  margin: 2rem auto;
  width: 40vw;
  max-width: 40rem;
  aspect-ratio: 1 / 1;
  outline: 1px solid #0ff;
}

#canvas canvas {
  display: block;
  width: 100%;
  height: auto;
}

#letters { display: none; }
<div id="canvas"></div>

<!-- SVG letter shapes, to be loaded in JS -->
<div id="letters">
  <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0" viewBox="0 0 200 200" xml:space="preserve">
    <path id="letter-B" fill="#00A15D" d="M187.5,101c3.3-5.6,4.9-12.1,4.9-19.3v-81h-62.7h-30H7.6v198h92.1v-36.4h-1.8v-30.6h1.8V97.3 h-1.8V36.9h1.9h2.4v56.8c0,2.5-0.8,3.7-2.4,3.7h0v34.3h2.4v26.1c0,1.2-0.2,2.3-0.5,3.2c-0.4,0.9-1,1.3-1.8,1.3h0v36.4h53.1  c12.7,0,22.4-3.6,29.3-10.7c6.9-7.1,10.3-16.5,10.3-28.1v-48.8h-13.5C182.4,108.3,185.3,104.9,187.5,101z" />
    <path id="letter-I" fill="#00A15D" d="M54.9 1h90.1v198H54.9Z" />
    <path id="letter-G" fill="#00A15D" d="M7.5,199V39.8c0-11.6,3.4-21,10.3-28.1C24.7,4.6,34.4,1,46.9,1h145.6v38.5H102   c-1.4,0-2.5,0.5-3.2,1.5c-0.7,1-1.1,2.1-1.1,3.3v117.2h4.2V81.3V43.8h90.4V199H7.5z" />
    <path id="letter-B2" fill="#F6B9AA" d="M152,102.6c3.3-5.6,4.9-12.1,4.9-19.3V1H43.1v69.5h54.6V35.8h4.2v58.9c0,2.5-0.8,3.7-2.4,3.7 h-1.8V70.3H43.1v83.5h54.6v-21.1h4.2v26.7c0,1.2-0.2,2.3-0.5,3.2c-0.4,0.9-1,1.3-1.8,1.3h-1.8v-10.8H43.1V199h74.2 c12.7,0,22.4-3.6,29.3-10.7c6.9-7.1,10.3-16.5,10.3-28.1v-48.6h-12.2C147.6,109,150,106.1,152,102.6z" />
    <path id="letter-A" fill="#F6B9AA" d="M43.1,1v91.1l54.7,1v-57h4.2V124h-4.2V87.1H43.1V199h54.7v-39.6h4.2V199h54.9V1H43.1z"/>
    <path id="letter-N" fill="#F6B9AA" d="M43.1 199L43.1 1L156.9 1L156.9 199L102 199L102 36.1L97.8 36.1L97.8 199z" />
    <path id="letter-D" fill="#F6B9AA" d="M43,1v114.5h54.6V36.1h4.2v123.8c0,0.9-0.2,1.7-0.5,2.5c-0.4,0.8-0.8,1.2-1.3,1.2h-2.4v-52.1 H43V199h75.2c7.6,0,14.3-1.7,20.1-5c5.8-3.3,10.4-7.9,13.7-13.7c3.3-5.8,5-12.2,5-19.3V1H43z" />
  </svg>
</div>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/decomp.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/pathseg.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/matter.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/matter-attractors.min.js"></script>

4
  • 2
    Excellent question, thanks for the complete snippet! It seems like it has to do with the complexity of the paths. If you make the other shapes simple rectangles like the "I", or make them circles, then the fill color changes work as expected. You might want to open an issue with MJS so the author can look into it--it seems like a bug in the library (or in an unusual interaction between the pathseg/decomp libraries).
    – ggorlen
    Commented Jul 6 at 15:13
  • 2
    Looks like it's possibly the intended behavior, based on this great answer, just a bit unintuitive, so an issue may not be necessary.
    – ggorlen
    Commented Jul 6 at 20:09
  • 1
    It really is a great answer that cleared things up for me. Unrelated to this though, I ended up switching to two.js as a renderer, which supports SVG output. It was a bit of work, but it made mouse interaction a bit more straightforward and solved the color issue in the process (paths are used as is, instead of broken up into parts).
    – oelna
    Commented Jul 6 at 21:41
  • Yes, avoiding the MJS renderer is often a good idea. I have numerous posts that show how you can use canvas, p5, pixi or HTML elements for the rendering front end. There's a summary in this thread. Feel free to post an answer with your approach--I've been interested in two.js but haven't experimented with it yet.
    – ggorlen
    Commented Jul 6 at 22:17

1 Answer 1

4

Looks like this issue occurs if the label has more than 1 part. The I label has only 1 part so it works properly but if it's more than 1, then when we update the fillStyle property, it only updates the first part's fillStyle attribute. You can review the parts array in the target object. It updates only the parts array's first element's fillStyle attribute. Therefore, to fix this problem, need to map parts and set the fillStyle property the same as the color we want to fill:

// *** Added this mapper to map parts and set the color ***
const partsMapper = (parts, color) => { 
  parts.forEach(part => {
      part.render.fillStyle = color;
      part.render.strokeStyle = color;
  });
}

Matter.Events.on(mouseConstraint, "mousedown", event => {
  const target = event.source.body;
  if (target) {
    // *** Filtered colors to not set the same color again ***
    const chosenColor = Matter.Common.choose(['#f19648', '#f5d259', '#f55a3c'].filter(color => color !== target.render.fillStyle));
    target.render.fillStyle = chosenColor;
    
    // *** Map parts ***
    partsMapper(target.parts, chosenColor);
    
    // applying force works for some reason
    const dir = Matter.Common.random(-0.3, 0.3);
    Matter.Body.applyForce(target, { x: target.position.x, y: target.position.y }, { x: dir, y: -0.2 });
  }
});

DEMO:

Matter.Common._seed = Math.random().toString().slice(2,10); // random 8 digit number
Matter.use(MatterAttractors);

const engine = Matter.Engine.create();
const currentWidth = document.querySelector('#canvas').offsetWidth * 2;

// letter initial coordinates
const offsets = {
  'B':  [currentWidth/100*20, currentWidth/100*5],
  'I':  [currentWidth/100*50, currentWidth/100*5],
  'G':  [currentWidth/100*70, currentWidth/100*5],
  'B2': [currentWidth/100*20, currentWidth/100*50],
  'A':  [currentWidth/100*40, currentWidth/100*50],
  'N':  [currentWidth/100*60, currentWidth/100*50],
  'D':  [currentWidth/100*80, currentWidth/100*50]
};

var render = Matter.Render.create({
  element: document.querySelector('#canvas'),
  engine: engine,
  options: {
    width: currentWidth, 
    height: currentWidth,
    background: '#222',
    wireframes: false,
    showAngleIndicator: false
  }
});

var select = function (root, selector) {
  return Array.prototype.slice.call(root.querySelectorAll(selector));
};

var loadSvg = function (url) {
  return fetch(url)
    .then(function(response) { return response.text(); })
    .then(function(raw) { return (new window.DOMParser()).parseFromString(raw, 'image/svg+xml'); });
};

// add SVG letters
document.querySelectorAll('#letters path').forEach(function (ele, i) {
  const color = ele.getAttribute('fill');
  const id = ele.getAttribute('id').replace('letter-', '');
  const vertexSets = Matter.Svg.pathToVertices(ele, 30);

  const offsetX = offsets[id][0];
  const offsetY = offsets[id][1];

  var letter = Matter.Bodies.fromVertices(offsetX, offsetY, vertexSets, {
    render: {
      fillStyle: color,
      strokeStyle: color,
      lineWidth: 1,
      opacity: 1
    },
    restitution: 0.7,
    label: 'Letter-' + id
  });

  Matter.Body.rotate(letter, Matter.Common.random(-0.3, 0.3));
  Matter.Body.scale(letter, 1.0, 1.0);
  Matter.Body.setMass(letter, 5.01);

  Matter.Composite.add(engine.world, letter, true);
});

// walls
const wallstyle = { fillStyle: '#0f0' };
var ground = Matter.Bodies.rectangle(currentWidth/2, currentWidth, currentWidth, 10, { isStatic: true, render: wallstyle });
var wallLeft = Matter.Bodies.rectangle(0, currentWidth/2, 10, currentWidth, { isStatic: true, render: wallstyle });
var wallRight = Matter.Bodies.rectangle(currentWidth, currentWidth/2, 10, currentWidth, { isStatic: true, render: wallstyle });

Matter.Composite.add(engine.world, [wallLeft, wallRight, ground]); // [boxA, boxB, ground]

Matter.Render.run(render);

var runner = Matter.Runner.create();
Matter.Events.on(runner, "tick", event => {}); // nothing as of right now
Matter.Runner.run(runner, engine);

// mouse events
const mouseConstraint = Matter.MouseConstraint.create(
  engine, 
  Matter.Mouse.create(render.canvas), 
  {}
);

// *** Added this mapper to map parts and set the color ***
const partsMapper = (parts, color) => { 
  parts.forEach(part => {
      part.render.fillStyle = color;
      part.render.strokeStyle = color;
  });
}

Matter.Events.on(mouseConstraint, "mousedown", event => {
  const target = event.source.body;
  if (target) {
    // *** Filtered colors to not set the same color again ***
    const chosenColor = Matter.Common.choose(['#f19648', '#f5d259', '#f55a3c'].filter(color => color !== target.render.fillStyle));
    target.render.fillStyle = chosenColor;
    
    // *** Map parts ***
    partsMapper(target.parts, chosenColor);

    // applying force works for some reason
    const dir = Matter.Common.random(-0.3, 0.3);
    Matter.Body.applyForce(target, { x: target.position.x, y: target.position.y }, { x: dir, y: -0.2 });
  }
});
html {
  background: black;
  color: #fff;
}

#canvas {
  margin: 2rem auto;
  width: 40vw;
  max-width: 40rem;
  aspect-ratio: 1 / 1;
  outline: 1px solid #0ff;
}

#canvas canvas {
  display: block;
  width: 100%;
  height: auto;
}

#letters { display: none; }

.as-console-wrapper {
  width: 0;
}
<div id="canvas"></div>

<!-- SVG letter shapes, to be loaded in JS -->
<div id="letters">
  <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0" viewBox="0 0 200 200" xml:space="preserve">
    <path id="letter-B" fill="#00A15D" d="M187.5,101c3.3-5.6,4.9-12.1,4.9-19.3v-81h-62.7h-30H7.6v198h92.1v-36.4h-1.8v-30.6h1.8V97.3 h-1.8V36.9h1.9h2.4v56.8c0,2.5-0.8,3.7-2.4,3.7h0v34.3h2.4v26.1c0,1.2-0.2,2.3-0.5,3.2c-0.4,0.9-1,1.3-1.8,1.3h0v36.4h53.1  c12.7,0,22.4-3.6,29.3-10.7c6.9-7.1,10.3-16.5,10.3-28.1v-48.8h-13.5C182.4,108.3,185.3,104.9,187.5,101z" />
    <path id="letter-I" fill="#00A15D" d="M54.9 1h90.1v198H54.9Z" />
    <path id="letter-G" fill="#00A15D" d="M7.5,199V39.8c0-11.6,3.4-21,10.3-28.1C24.7,4.6,34.4,1,46.9,1h145.6v38.5H102   c-1.4,0-2.5,0.5-3.2,1.5c-0.7,1-1.1,2.1-1.1,3.3v117.2h4.2V81.3V43.8h90.4V199H7.5z" />
    <path id="letter-B2" fill="#F6B9AA" d="M152,102.6c3.3-5.6,4.9-12.1,4.9-19.3V1H43.1v69.5h54.6V35.8h4.2v58.9c0,2.5-0.8,3.7-2.4,3.7 h-1.8V70.3H43.1v83.5h54.6v-21.1h4.2v26.7c0,1.2-0.2,2.3-0.5,3.2c-0.4,0.9-1,1.3-1.8,1.3h-1.8v-10.8H43.1V199h74.2 c12.7,0,22.4-3.6,29.3-10.7c6.9-7.1,10.3-16.5,10.3-28.1v-48.6h-12.2C147.6,109,150,106.1,152,102.6z" />
    <path id="letter-A" fill="#F6B9AA" d="M43.1,1v91.1l54.7,1v-57h4.2V124h-4.2V87.1H43.1V199h54.7v-39.6h4.2V199h54.9V1H43.1z"/>
    <path id="letter-N" fill="#F6B9AA" d="M43.1 199L43.1 1L156.9 1L156.9 199L102 199L102 36.1L97.8 36.1L97.8 199z" />
    <path id="letter-D" fill="#F6B9AA" d="M43,1v114.5h54.6V36.1h4.2v123.8c0,0.9-0.2,1.7-0.5,2.5c-0.4,0.8-0.8,1.2-1.3,1.2h-2.4v-52.1 H43V199h75.2c7.6,0,14.3-1.7,20.1-5c5.8-3.3,10.4-7.9,13.7-13.7c3.3-5.8,5-12.2,5-19.3V1H43z" />
  </svg>
</div>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/decomp.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/pathseg.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/matter.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/matter-attractors.min.js"></script>

Not the answer you're looking for? Browse other questions tagged or ask your own question.