Devlog

Why I Chose Konva.js, and What It Cost Me

The canvas is the hardest part of Vernal to build. I want to be specific about why.

Vernal's garden canvas handles two fundamentally different kinds of shapes. Rectangular beds that snap to a square-foot grid, where placement is precise and predictable. And freeform beds (organic polygon shapes for the plots that don't conform to a rectangle, because real gardens rarely do). These aren't just different shapes. They require different drawing modes, different hit detection logic, different resize behavior, and different placement rules. The canvas has to manage both cleanly and treat them as equals.

On top of that: touch support, because this is a tool people should be able to use on a tablet while standing in the garden. Real-time companion conflict detection, with warnings appearing as plants get placed, not after everything is already arranged. And all of it has to feel instant, because sluggishness in an interactive canvas doesn't just annoy; it breaks the experience of working spatially.

I chose Konva.js for this, via the react-konva binding. It runs on the HTML5 Canvas API rather than the DOM, which means rendering is GPU-composited instead of triggering DOM repaints; that distinction matters when you have many interactive elements moving around. Its architecture is layered: a static layer for elements that don't change (the grid) and an interactive layer for everything that does, so only the relevant layer re-renders when a bed moves. And its touch event handling was the most complete of the options I evaluated.

The alternatives I considered: rolling the canvas directly against the Canvas API would have given maximum control, but everything Konva already handles would need to be built from scratch. SVG is elegant for simpler graphics, but its performance degrades at canvas scale with many interactive elements. Fabric.js is capable and battle-tested, but its object model carried more overhead than this canvas needed. Konva was the right call.

What I didn't know yet was which parts of it I could actually rely on.

Before writing any production canvas code, I built a throwaway prototype. Five core interactions, no persistence, no backend. The sole purpose was to answer one question: do the core interaction patterns actually work? The prototype said yes. The production build started from there.

I mention this because prototypes sometimes get treated as optional. Committing to a full implementation spec before validating the core interactions would have been a guess. The prototype made it a decision.

Here's what building the canvas actually taught me.

Gotcha one: Konva's hit detection.

Hit detection is the mechanism that determines which object on the canvas was clicked. Konva has a built-in system for this. After Phase 17 (the core canvas, both bed types, grid snapping, freeform polygon drawing), I started testing it.

The results were inconsistent. Clicking a rectangular bed would sometimes trigger the info pane. Sometimes clicking a freeform bed would. Sometimes neither would respond. There was no consistent failure mode, no reproducible sequence that always broke it. Just intermittent misfires that made the canvas feel broken in testing.

The fix was to stop using Konva's hit detection entirely and rewrite it in plain JavaScript. Since then, it works correctly every time.

To say it plainly: Konva is a well-regarded library, and the hit detection failure was a surprise. The lesson isn't "don't use Konva." It's narrower than that: know which parts of a library you're actually depending on, and test them early, before you've built much on top of them.

Gotcha two: the race condition.

Phase 18 introduced bed movement, the ability to drag a placed bed and reposition it on the canvas. Getting this to work consistently for both bed types took more debugging than I expected.

The root cause was a race condition between two event handlers. When you drag and release, the browser fires mouseup and then click in sequence. My handleClick function was nulling moveRef, the reference tracking the active move operation, before handleMouseUp had finished committing it. That sequence corrupted the move state, and beds behaved unpredictably when repositioned.

The fix established a clean ownership rule. handleClick no longer touches moveRef at all. A new moveDoneRef flag handles click suppression, mirroring the resizeDoneRef pattern that already existed for resize interactions. handleMouseUp is now the sole owner of moveRef's lifecycle: it opens it, it closes it, nothing else touches it.

After that, movement worked consistently for both bed types.

Race conditions in event handlers are easy to miss because the failure is timing-dependent. You can test the same interaction a dozen times without triggering it, then hit it repeatedly once something else in the environment shifts. The fix, once found, wasn't complicated. Finding it was.

Four phases in, the canvas does what it set out to do. Rectangular beds snap to the square-foot grid. Freeform beds can be drawn and placed for plots that don't fit a rectangle. Both types move and resize. Plants can be searched and assigned to any bed, with the canvas calculating how many fit based on each plant's spacing requirements. And when plants with known companion conflicts end up in the same bed, warnings appear directly on the canvas in real time, without any page interaction required. That's where things stand. The canvas had to work first.

Follow the build
Want the next post in your inbox? I write when there's something worth saying.