{
"path": "/posts/skittles",
"site": "at://did:plc:pans3xjam4khj7y54dx7gtfg/site.standard.publication/3mdqevmg6w32c",
"tags": [
"javascript",
"graphics",
"photography"
],
"$type": "site.standard.document",
"title": "Monochrome Skittles",
"description": "Reverse engineering Apple's 2D style edit slider for the web, with some React and a whole bunch of math.",
"publishedAt": "2024-10-22T04:32:15.000Z",
"textContent": "<a href=\"/dots\" target=\"_blank\" rel=\"noopener noreferrer\">Photo-editor</a> | <a href=\"/skittles\" target=\"_blank\" rel=\"noopener noreferrer\">Debugger</a>\n\nThere hasn't been a lot of advancement in the ease of use of photo editing tools, and the barrier remains fairly high.\nGenerative AI doesn't do as much for photo editing as it does for full blown photo generation.\nSo while the new iPhone and iOS launches have all been about Apple Intelligence, the feature that I've loved is the new style edit tool in the Photos app. Style edit is the much needed improvement in editing UX. It's akin to Prometheus making fire accessible to mere mortals.. well mortals who own an iPhone 16.\nIt’s a 2-dimensional slider that combines something they call a palette, and a general 3-channel curves tool.\nAfter testing a few styles I’ve concluded that what they call palette is just a look-up-table (LUT). Something colorists have used for decades to create a consistent visual language through color scheme. It’s an incredibly fast way to alter the look and feel of an image, but I digress.\n\nWhy do I care so much?\n\nWhen Douglas Engelbart first introduced the mouse in '68 it opened UX possibilities that are still the primary mode of interacting with a computer. Multi-touch screens ushered in a new era of user experiences that were not possible before, eg. doomscrolling.\nNow I can’t claim that the 2D slider is going to change our world, but for lazy photographers like myself it might be the best compromise between unedited photos and a cohesive visual language.\n\nWhat's annoying though is that Style editor is paywalled behind the latest version of the iPhone, and it only works on the photos taken with the iPhone 16. Bummer, I wonder if I can just write a web version.\n\nThis is what it looks like:\n\n{{< video src=\"/dots-editor.webm\" type=\"video/webm\" >}}\n\nLet's bring it to the web, shall we?\n\nAs I like to have fun and not burning out on side-projects, I decided to build the toy first, that is the 2D slider component.\n\nRendering the dots\n\nI start off by creating a dot grid. CSS grid made this trivial once I decided on the number of rows and columns.\nOnce the cells are in place, I render a circle in the middle of each cell.\n\nI wanted to make the size of each dot react to the distance from the pointer. And also ensure that the effect dissipates quickly leaving any dots outside of a certain distance un-affected.\nAs I want the strength of the effect to decrease sharply I can make it proportional to the inverse of some power of the distance. Square should work, right?\n\nCalculating distance of dots\n\nTo calculate the distance I need the position of the cell’s center where the dot is rendered.\nOnce the cell is rendered and its layout calculated, I use getBoundingClientRect to get its position and size. Position of the center is just:\nx = (rect.left + rect.width / 2)\ny = (rect.top + rect.height / 2)\n\nTo validate I can render the actual value of the square distance inside each cell.\n\nsquareDist = (dot.x - touchPosition.x)^2 + (dot.y - touchPosition.y)^2\n\nCurrently this gives me actual pixel distance, but what I really want is a relative distance grounded in the bounds of the container, in a range [0, 1].\nFor instance if I move the pointer to a corner of the container, the diagonally opposite corner should be at a distance of 1.\n\nThe normalized square distance then becomes: squareDist / (diagonal length)^2\nThis ensures that the values we get are always under 1.\n\nFrame of reference\n\nThe diagonal length comes from the container.\nTo get the bounds of the container I use getBoundingClientRect. It provides the left, top, height, and width of the component.\nFrom the touch events I already have the position of the cursor, so I can determine the cursor position relative to the container.\nx = touchPosition.x - rect.left\ny = touchPosition.y - rect.top\nnormalized(x) = (touchPosition.x - rect.left) / rect.width\nnormalized(y) = (touchPosition.y - rect.top) / rect.height\nThen clamping the value to (0, 1) would give me the position inside the container.\n\nDot sizes\n\nNow that I have the normalized distance, I can use that to bump the size of each dot based on an arbitrary constant multiple: SCALE_MULTIPLE (1 - normalizedSquareDist)\nBut before that I want to debug the normalized distance values.\nA fairly intuitive way to do this is to use hsl color values. And I replaced the dots with actual clampedScale values with two precision points.\n\nCSS makes the first part trivial: hsl(${(1 - normalizedSquareDist) 360}, 100%, 60%). As hue values range between [0, 360], and normalizedSquareDist ranges between [0, 1], hence (1 - normalizedSquareDist) 360 becomes [360, 0] and gives me the whole rainbow.\n\n{{< video src=\"/skittles-1.webm\" type=\"video/webm\" >}}\n\nCheck out the interactive version <a href=\"https://tauseefk.github.io/skittles/?variant=1\" target=\"_blank\" rel=\"noopener noreferrer\">here</a>.\n\nInteresting results\n\nNow if I add scale multiple on top of the square distance and scale the text content, I get an interesting result.\nThe numbers are almost unreadable closer to the cursor, and it affects almost the entire grid.\n\n{{< video src=\"/skittles-2.webm\" type=\"video/webm\" >}}\n\n<a href=\"https://tauseefk.github.io/skittles/?variant=2\" target=\"_blank\" rel=\"noopener noreferrer\">Interactive</a>\n\nScale multiple with faster attenuation\n\nTo reduce the radius of the scaling, I can make the initial fraction smaller i.e. 1 - normalizedSquareDist <some-multiple>.\nThis causes the scaling to drop sharply creating a blob quite similar to what I initially set out to make.\n\n{{< video src=\"/skittles-3.webm\" type=\"video/webm\" >}}\n\n<a href=\"https://tauseefk.github.io/skittles/?variant=3\" target=\"_blank\" rel=\"noopener noreferrer\">Interactive</a>\n\nSkittles\n\nOnce I had the attenuation effect working, I wanted to turn the \"debugger\" off and go back to rendering the dots.\nThis is my favorite version, I wonder what the cat thinks.\n\n{{< video src=\"/skittles-4.webm\" type=\"video/webm\" >}}\n\n<a href=\"https://tauseefk.github.io/skittles/?variant=4\" target=\"_blank\" rel=\"noopener noreferrer\">Interactive</a>\n\nMonochrome, just like Steve wanted\n\nI simplified the effect a little bit so I don't get distracted by my own UI. This is supposed to be a photo editor remember!\nTo add just a little bit of visual interest I applied similar but simpler math to the opacity.\n\n{{< video src=\"/skittles-5.webm\" type=\"video/webm\" >}}\n\n<a href=\"https://tauseefk.github.io/skittles/?variant=5\" target=\"_blank\" rel=\"noopener noreferrer\">Interactive</a>\n\nWhat's next?\n\nThis is only the beginning, I haven't built a renderer yet.\nI'll write about that next time as it's too late and I wanna go back to reading my book.",
"canonicalUrl": "https://afloat.boats/posts/skittles"
}