Case study
Mystery Mosaics
Turns an uploaded picture into a printable colour by number mosaic chart, in four cell shapes and three palette sizes, computed entirely in the browser.
Overview
Try it: the demo is live and gated behind a password, which is literally the word password.
A picture goes in, a numbered mosaic chart comes out: a grid of shapes, each one reduced to a single colour from a fixed numbered palette, ready to print at A3 or to fill in on screen. Everything runs in the browser. No account, no upload to a server, nothing to install.
It was commissioned as a simplified version of an existing product, with a one month delivery estimate. It ran considerably longer than that, because each delivery produced the next request: three palette sizes, then colour replacement, then three more cell shapes, then a print pipeline to match. What follows is the part that turned out to be hard.
Complexities tackled
Matching a colour the way an eye would, not the way a computer would. Each cell takes the colour that occurs most often inside it, then snaps to the nearest entry in the palette. The first version measured “nearest” as straight line distance between red, green and blue values, which is the obvious implementation and is wrong in a way that only shows up in use: greens came out grey. RGB distance weights the three channels equally, and human vision does not, so desaturated greens end up measurably closer to a grey than to the green a person would pick. The fix was to convert both colours through to CIE Lab, a space built so that equal numeric distances look like equal differences, and measure there instead. Reds and blues had always looked fine, which is exactly why the bug survived the first round of testing.
Four tessellations, and two of them are the whole job. Squares and circles are the easy pair.
They share one addressing scheme: cell (x, y) maps straight onto pixel (x * size, y * size), and
working out which cell a click landed in is a rectangle test. Anyone can do that. Triangles and
hexagons share none of it, and between them they took longer than everything else in the project
combined.
Triangles. A triangular tessellation is not a grid of triangles, it is two interleaved grids. Every row holds twice as many cells as a square row, orientation alternates cell by cell, and the vertical pitch is the height of an equilateral triangle rather than its side, so nothing lines up with anything. Worse, two adjacent triangles occupy the same bounding box: point in rectangle tells you nothing about which of the pair you hit. Hit testing had to move to barycentric coordinates, computing where a point sits relative to the three real vertices. Getting from the first working grid to correct rendering, correct hit testing and correct export took three weeks, and the commit that ended it is not politely named.
Hexagons. A honeycomb is worse again, because a hexagon is neither a rectangle nor a triangle and has no shortcut. Flat topped hexagons pack at one and a half times the side length horizontally and half the hexagon height vertically, with every other row offset, so the mapping from screen position to cell is not derivable by division at all. Hit testing runs a winding number algorithm over the six real vertices: for each edge, count the crossings of a ray from the point, and if the winding number is non zero the point is inside. That is a general polygon containment test, written because the cheap tests all give wrong answers on a shape that tiles diagonally.
What made the second one survivable. Every consumer of the grid, drawing, export sizing, canvas fitting, colour counting, had quietly assumed squares. Rather than special casing each of them twice, the shapes moved behind one interface covering grid generation, hit testing, path drawing, bounding box and padding. Triangles paid for that refactor in full. Hexagons afterwards were an implementation of an existing contract rather than another round of surgery, which is the entire argument for having done it.
Print output that is genuinely print output. Three views, flat colour, numbers only, and both, across three file formats, is nine combinations that each need different treatment: a numbered PNG exports on transparency so it can be laid over something else, everything else on white. PDFs are composed in millimetres at A3 with a margin and rendered at 300 DPI, alongside a colour key band and a border drawn into the same canvas, rather than being a screen capture in a PDF wrapper.
A grid too big to redraw, on a canvas that pans and zooms. A chart can run to thousands of cells, and pixel data is pulled at chart resolution through a GPU accelerated renderer instead of being read back cell by cell. Zoom is anchored to the pointer, so the point under the cursor stays under the cursor, with the offset clamped both ways so the grid can never be dragged off its own canvas.
Validating the specification instead of implementing it. The palettes arrived as a supplied document. Reading it before building found a gap in the numbering, one entry that had been left out of a list that was supposed to be complete, and a duplicate colour in another. Both were reported, with a suggested fix for the first and a tool for checking the second. A specification that is wrong is cheaper to catch on the way in.
Two questions before writing the replace colour feature. What happens when the replacement colour is already present in the picture, and what happens to cells where the original colour had been erased. Both are ambiguous in the one line version of the request, both change the implementation, and both are much cheaper to ask about than to guess at.
Recommending against work already quoted. A request to switch palette size partway through looks like a menu change and is not. Palette identity is baked into the cell numbering, so changing it means requantising the whole picture and deciding which colours merge into which. That reasoning was put to the client and the feature was withdrawn from the quote. A separate request for an irregular, broken glass style tessellation was researched, found to produce poor results with everything available, and declined on those grounds rather than shipped in a weak form.
Handing over to someone who does not use version control. Deployment ran from continuous integration, but the client had no interest in a Git account, and a handover that depends on the client adopting the developer’s toolchain is not a handover. The built output was packaged so the site could be hosted anywhere, and the DNS migration was written out in the order it actually has to happen, signing disabled first and allowed to propagate before the nameservers move, or resolution breaks for anyone holding a cached record.
One production incident that was correctly left alone. A report came in that the site was unreachable from another country. The right first move was a cheap test to isolate where the fault was, not a change to a system that had not been touched. It was not the site.
What a one month brief turned into
The posting asked for a simplified version of an existing product and estimated a month. The client also wrote that the budget was flexible and that a first delivery might lead to further expansions, and that is exactly what happened: it worked, and the work kept extending from there.
The useful skill in that is not estimating month one accurately. It is building month one so that month seventeen is still possible: keeping the structure open, not hard coding the assumptions the original scope made look safe, and being willing to say plainly what a new request will cost, or that it should not be built at all. The shape interface that absorbed hexagons, and the palette switcher argued out of the quote, are both that same habit.
Stack
Vue 3 with TypeScript, Vuex for state and Vue Router, styled with Tailwind CSS. Image sampling runs
through PixiJS; the grid, the editing canvas and the export canvases are HTML5 Canvas with Path2D
geometry and hand written zoom and pan. Colour work uses chroma-js alongside a purpose written sRGB
to XYZ to CIE Lab module for the perceptual distance. Export goes through jsPDF for A3 print and
canvas encoding for JPG and PNG. Deployed on Cloudflare Pages with deployment on push.
Working on something similar?
Tell me what you are building and what is in the way. I will tell you honestly whether I am the right person for it.