Search code examples
javascripthtmlhtml5-canvas

Why does the Canvas API fill parts of these paths within a loop with the wrong color?


I’ve created a JSFiddle with all of the code active and running.

The relevant JS is here:

const canvas = document.getElementById("base");
const ctx = canvas.getContext("2d");
const cWidth = canvas.width;
const cHeight = canvas.height;
const padding = 4;
const portSetCount = 96;
const portSetRows = 6;
const portsPerRow = portSetCount / portSetRows;
const drawablePortWidth = cWidth - ((portsPerRow + 1) * padding);
const portWidth = drawablePortWidth / portsPerRow;
const portSixth = portWidth / 6;
let x = padding;
let y = padding;

for (let row = 1; row <= portSetRows; row++) {
  x = padding;

  for (let col = 1; col <= portSetCount / portSetRows; col++) {
    ctx.fillStyle = "green";
    ctx.rect(x, y, portWidth, portWidth);
    ctx.fill();

    ctx.fillStyle = "black";
    ctx.beginPath();
    ctx.moveTo(x + portSixth, y + (2 * portSixth));
    ctx.lineTo(x + portSixth, y + (5 * portSixth)); // Left.
    ctx.lineTo(x + (5 * portSixth), y + (5 * portSixth)); // Bottom.
    ctx.lineTo(x + (5 * portSixth), y + (2 * portSixth)); // Right.
    ctx.lineTo(x + (4 * portSixth), y + (2 * portSixth));
    ctx.lineTo(x + (4 * portSixth), y + (1 * portSixth));
    ctx.lineTo(x + (2 * portSixth), y + (1 * portSixth));
    ctx.lineTo(x + (2 * portSixth), y + (2 * portSixth));
    // ctx.lineTo(x + (1 * portSixth), y + (2 * portSixth));
    ctx.closePath();
    ctx.fill();

    x += portWidth + padding;
  }

  y += portWidth + padding;
}
#stage {
  width: 1000;
  height: 500px;
  position: relative;
  border: 2px solid black;
}

canvas {
  position: absolute;
}

#overlay {
  z-index: 2;
}

#base {
  z-index: 1;
}
<div id="stage">
  <canvas id="base" width="1000" height="500"></canvas>
  <canvas id="overlay" width="1000" height="500"></canvas>
</div>

For some reason, it ends up rendering like this, where most of the inner shapes don’t actually fill and even their borders seem to be missing some lines.

Screenshot of an area with individual green squares arranged in a 16 by 6 grid. Each square contains a few vague black outlines. The last square in the bottom right corner has a properly filled black shape of an Ethernet port. This is referred to as “the port shape”.

However, if I comment out the green background, it does work like so, where the shapes are all filled in as you would expect.

Screenshot of the same 16 by 6 grid, but this time, there are no green squares. Every single cell in the grid is filled with a black “port shape”.

After posing the question, I was able to “solve” the issue by tweaking the relevant code segment to look like below:

ctx.beginPath(); // Added this line.

ctx.fillStyle = "black";
ctx.rect(x, y, portWidth, portWidth);
ctx.fill();

ctx.fillStyle = "green";

Screenshot of the same 16 by 6 grid, this time all black “port shapes” properly filled on top of green squares.

Basically, my initial attempt was “conceptually” trying to create a green square and then overlay a black shape on top of it. Something was going on weird with that so instead I created one continuous thing (Path? Shape?) composed of two nested things (I don’t know the right canvas terminology) with two separate fills, and then that somehow worked.

I’m not putting this as an answer since it isn’t, but it is how I have accomplished my desired effect for now.


Solution

  • The simple fix for this issue is to replace rect by fillRect and remove the subsequent fill. rect adds a rectangle onto the current subpath, whereas fillRect is a self-contained operation that creates its own path and fills it.

    This is also explained in HTML5 Canvas - fillRect() vs rect().

    Now, why do your ports look the way they do? The code actually always leaves the last port correctly filled in — this is always true, even in the middle of program execution, at the end of any iteration. But as soon as the next green rectangle is filled in, the previous port also gets filled green. The reason is: they’re part of the same path!

    The following table and graphic are a few prerequisites to understanding path creation in the Canvas API.

    You can group some of the methods you used into two categories: those that pertain to the entire current path, and those that pertain to the current subpath. A path can be comprised of multiple subpaths. Let’s see how they relate to one another:

    Method Pertains to Links WHATWG specification MDN documentation
    beginPath Path WHATWG, MDN “[…] empty the list of subpaths in [the] current default path so that it once again has zero subpaths.” “[…] starts a new path by emptying the list of sub-paths. Call this method when you want to create a new path.”
    moveTo Subpath WHATWG, MDN “Create a new subpath with the specified point as its first (and only) point.” “[…] begins a new sub-path at the point specified by the given (x, y) coordinates.”
    lineTo Subpath WHATWG, MDN “If the […] path has no subpaths, then ensure1 there is a subpath for (x, y). Otherwise, connect the last point in the subpath to the given point (x, y) using a straight line, and then add the given point (x, y) to the subpath.” “[…] adds a straight line to the current sub-path by connecting the sub-path’s last point to the specified (x, y) coordinates.”
    closePath Subpath WHATWG, MDN “[…] must do nothing if the […] path has no subpaths. Otherwise, it must mark the last subpath as closed, create a new subpath whose first point is the same as the previous subpath’s first point, and finally add this new subpath to the path. If the last subpath had more than one point in its list of points, then this is equivalent to adding a straight line connecting the last point back to the first point of the last subpath, thus ‘closing’ the subpath.” “[…] attempts to add a straight line from the current point to the start of the current sub-path. If the shape has already been closed or has only one point, this function does nothing.”
    fill Path WHATWG, MDN “Subpaths with only one point are ignored when painting the path.”; “[…] fill all the subpaths of the […] path […]. Open subpaths must be implicitly closed when being filled (without affecting the actual subpaths).” “[…] fills the current or given2 path with the current fillStyle.”

    This graphic should summarize all these actions:

    A graphical explanation of the mechanisms in the table above is presented. The graphic has a dark gray background. Three simple polygons are shown in a row. The exact shapes do not matter, but the polygon on the left and the one on the right have four corners each, the one in the middle has three. A white curly bracket spans the left and middle polygon, and is labeled “Path”. Another white curly bracket spans the polygon on the right, also labeled “Path”. Three yellow curly brackets span each polygon individually, each one labeled “Subpath”. The white curly brackets are at the bottom, the yellow ones at the top. This is to demonstrate that the left and middle polygon are part of the same path, but split into two subpaths, whereas the polygon on the right is its own path and consists of a single subpath. Near the top left corner of the graphic is a brown point labeled “Origin”. A light gray arrow is connected from the origin to one of the corners of the polygon on the left. The arrow is labeled “moveTo”. Four more arrows travel from corner to corner to trace the outline of the polygon on the left. Each of these arrows is labeled “lineTo”, except the last one, which is labeled “closePath”. This means, the last arrow points to the same corner as the first “moveTo” arrow. From this point on, another arrow labeled “moveTo” connects to the polygon in the middle, where three more arrows trace out its outline with the labels “lineTo”, “lineTo”, and “closePath”, respectively. The label “beginPath” floats near that last corner. Another arrow labeled “moveTo” points to a corner of the polygon on the right, but the start of the arrow is invisible and transitions into visibility as it approaches the corner of the polygon on the right. This means that the arrow has a color gradient from transparent to light gray. This is to demonstrate that an entirely new path begins here and there is no real connection between the polygon in the middle and the polygon on the right, other than the fact that technically, the “moveTo” method does indeed come from the corner of the middle polygon. The final four edges of the polygon on the right are traced with arrows, again all labeled “lineTo”, except the last one, labeled “closePath”. Every “closePath” arrow has a little yellow circle around its target vertex. These circles are labeled “Subpath” to demonstrate that these vertices (after the “closePath”) constitute their own subpaths. All polygons are traced in the counterclockwise direction, although this doesn’t matter in practice. The “beginPath” label is white and matches the color of the “Path” label. The “moveTo” and “closePath” labels are yellow and match the color of the “Subpath” label. The “lineTo” labels are light green. This is to demonstrate the relation between different method calls and the creation of paths and subpaths. The arrows show in which order the paths and subpaths are created and in which order the vertices are added. The left and middle polygons are filled purple, the polygon on the right is filled dark red. Three “fill” labels are in the bottom row. The first one represents a “fill” call after the first “closePath” call: from the label, an arrow points inside the polygon on the left. The second “fill” label represents a “fill” call after the second “closePath” call: from the label, one arrow points inside the polygon on the left and another arrow points inside the polygon in the middle. The last “fill” label represents a “fill” call after the last “closePath” call: from the label, an arrow points only inside the polygon on the right. Each “fill” label and their respective arrows have a similar color to the respective fill colors of each polygon. This is to demonstrate that “fill” calls pertain to the entire current path, which include all of their subpaths (with more than one vertex).

    rect, like its related methods, adds to the current subpath.

    The distinction between (entire) path and subpath is important, but easily missed: beginPath creates an entirely new path, and moveTo creates a subpath, but fill fills entire paths. Furthermore, what is filled once, stays filled. This is like paint: when you paint the canvas once, and are told to fill an overlapping region, you’ll have to paint over your older paint.

    This helps explain why these black outlines exist: your port shapes are filled black, but then the same shape — plus a rectangle — are filled green; the green overlays the black. Note that division by 6 doesn’t always result in an integer, so each fill has some transparency towards the edge. That’s why the green doesn’t completely cover the black at the edges.

    It is important to note that closePath is not the “opposite” of beginPath. closePath only closes and opens subpaths; it does not change what the current path is as beginPath does. This is further complicated by the fact that beginPath and closePath are sometimes optional: for instance, a moveTo is only supposed to create a subpath, but if it’s called at the very beginning, when no path exists, a path is automatically created as well, making a beginPath before moveTo redundant.

    Let’s go through the script step by step, by considering two iterations one after the other:

    1. First iteration
      1. ctx.fillStyle = "green"; sets the fill color for the next fill call to "green".
      2. ctx.rect(x, y, portWidth, portWidth); requires a subpath to exist. Since no paths exist at the beginning, a new path is automatically created, and a subpath is created as part of it. Then, a rectangle is added to the current subpath.
      3. ctx.fill(); fills the interior of the entire current path (i.e. all of its subpaths: the rectangle) green.
      4. ctx.fillStyle = "black"; sets the fill color for the next fill call to "black".
      5. ctx.beginPath(); discards the previous path and creates a new path. This is now the current path, and the previous rectangle path is no longer accessible.
      6. ctx.moveTo(x + portSixth, y + 2 * portSixth); creates a new subpath with the specified position as its first vertex.
      7. The seven ctx.lineTo(); calls add vertices to the current subpath.
      8. ctx.closePath(); is like a lineTo back to the start of the current subpath; the subpath is closed and a new subpath is created.
      9. ctx.fill(); fills the interior of the entire current path (i.e. all of its subpaths: the port shape) black.
    2. Second iteration (Steps 1 and 4 are no different than in the first iteration)
      1. (ctx.fillStyle = "green";)
      2. ctx.rect(x, y, portWidth, portWidth); adds a rectangle to the current subpath, which already exists: it starts at the end of the port shape of the previous iteration.
      3. ctx.fill(); fills the interior of the entire current path (i.e. each subpath: the rectangle plus the port shape of the previous iteration) green.
      4. (ctx.fillStyle = "black";)
      5. ctx.beginPath(); discards the previous path and creates a new path. Now the rectangle plus port shape are no longer accessible.
      6. Etc.

    The second iteration is where the bug happens. In its third step, fill fills a path that has been opened in the previous iteration using beginPath.

    As you found out, you can fix it without fillRect, by simply placing ctx.beginPath(); before ctx.rect(); — or, equivalently, at the start of the iteration. Now you should understand why.


    1: The phrase “ensure there is a subpath” has its own algorithm steps: “When the user agent is to ‘ensure there is a subpath’ for a coordinate (x, y) on a path, the user agent must check to see if the path has its ‘need new subpath’ flag set. If it does, then the user agent must create a new subpath with the point (x, y) as its first (and only) point, as if the moveTo method had been called, and must then unset the path’s ‘need new subpath’ flag.”.

    2: fill does indeed accept a specific Path2D as an argument. Without an argument, it fills the current path of the provided rendering context.