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.
However, if I comment out the green background, it does work like so, where the shapes are all filled in as you would expect.
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";
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.
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:
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:
ctx.fillStyle = "green";
sets the fill color for the next fill
call to "green"
.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.ctx.fill();
fills the interior of the entire current path (i.e. all of its subpaths: the rectangle) green.ctx.fillStyle = "black";
sets the fill color for the next fill
call to "black"
.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.ctx.moveTo(x + portSixth, y + 2 * portSixth);
creates a new subpath with the specified position as its first vertex.ctx.lineTo(
…);
calls add vertices to the current subpath.ctx.closePath();
is like a lineTo
back to the start of the current subpath; the subpath is closed and a new subpath is created.ctx.fill();
fills the interior of the entire current path (i.e. all of its subpaths: the port shape) black.ctx.fillStyle = "green";
)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.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.ctx.fillStyle = "black";
)ctx.beginPath();
discards the previous path and creates a new path. Now the rectangle plus port shape are no longer accessible.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.