Making Iroshizuku Interactive with Observable

Wait 5 sec.

[This article was first published on CHI(χ)-Files, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)Want to share your content on R-bloggers? click here if you have a blog, or here if you don't. One tiny dataset, another rabbit hole In the previous post, I turned 24 Pilot Iroshizuku fountain pen inks into a tiny shop using ggplot2.But while sorting the inks by colour, I started wondering what it would look like if I could rearrange them interactively.I had also been meaning to try something completely new to me: Observable JS inside a Quarto document.So this post is partly an ink experiment and partly me figuring out how R, Observable, and Quarto fit together.The plan is pretty small:Take the same 24 inks, pass the data from R to Observable, and start moving things around.And while I’m here, I want to play with two perceptual colour representations:HCL, which I used in the previous postOKLCH, which I keep seeing pop up in modern web/CSS colour discussionsLet’s see where this goes.The dataSame 24 inks as before.Codeiroshizuku_colors rename( hcl_h = H, hcl_c = C, hcl_l = L )# OKLCHoklch_coords as_tibble() |> rename( oklch_l = l, oklch_c = c, oklch_h = h )ink_data bind_cols(hcl_coords, oklch_coords) |> mutate( original_order = row_number() ) |> select( original_order, ink_name, ink_name_japanese, description, hex, hcl_h, hcl_c, hcl_l, oklch_h, oklch_c, oklch_l )ink_data# A tibble: 24 × 11 original_order ink_name ink_name_japanese description hex hcl_h hcl_c hcl_l 1 1 Ajisai 紫陽花 Hydrangea #125… 254. 70.0 36.4 2 2 Asagao 朝顔 Morning Gl… #043… 261. 67.8 24.2 3 3 Konpeki 紺碧 Deep Cerul… #036… 250. 74.5 43.1 4 4 Amairo 天色 Sky Blue #00A… 237. 76.5 62.1 5 5 Kujaku 孔雀 Peacock #028… 189. 40.4 51.4 6 6 Rikka 立夏 Early Summ… #1A7… 233. 52.4 49.0 7 7 Tsukiyo 月夜 Moonlit Ni… #016… 228. 44.5 42.5 8 8 Shinkai 深海 Deep Sea #1C3… 253. 36.9 24.4 9 9 Syoro 松露 Dew on Pin… #077… 156. 41.9 46.310 10 Shinryo… 深緑 Forest Gre… #007… 145. 48.9 46.3# ℹ 14 more rows# ℹ 3 more variables: oklch_h , oklch_c , oklch_l Codeggplot(plot_data, aes(x = x, y = y)) + geom_tile( aes(fill = I(hex)), width = 0.98, height = 0.96 ) + geom_text( aes( label = label_vertical, colour = I(text_colour) ), family = "osaka", lineheight = 0.85, size = 3.5 ) + facet_wrap( ~ prop_label, scales = "free", ncol = 1 ) + theme_void(base_family = "osaka") + theme( plot.background = element_rect( fill = "#F5F1E8", colour = NA ), panel.background = element_rect( fill = "#F5F1E8", colour = NA ), strip.background = element_blank(), strip.text = element_text( size = 10, face = "bold", margin = margin(b = 8) ), panel.spacing = unit(1.1, "lines"), plot.title = element_text( size = 12, face = "bold", margin = margin(b = 12) ), plot.margin = margin(20, 20, 20, 20) ) + labs( title = "24 inks, 3 ways of seeing them" )The same 24 inks sorted by hue, chroma, and lightness in HCL and OKLCH.Mostly I just want to get this little table out of R and into JavaScript so I can start moving things around in browser!R, meet Observable This part felt slightly magical the first time it worked.Quarto’s ojs_define() lets me create something in R and hand it over to Observable running in the browser.So R says: Here are my 24 inks.Observable says: Thanks. Now let me play with them.flowchart LR R[R 🐣prepare data] --> Q[Quarto 📦hand it over] Q --> O[Observable ✨play in the browser]That handoff is basically the whole experiment.R still does the data wrangling I’m comfortable with. Quarto acts as the bridge. Observable takes over once I want the page itself to react.Reference: https://quarto.org/docs/computations/ojs.htmlThe R data frame needs one small reshaping step on the Observable side.Codeink_rows = transpose(inks)After that, Observable can work with it like ordinary JavaScript data.CodeInputs.table(ink_rows, { columns: [ "ink_name_japanese", "ink_name", "description", "hex", "hcl_h", "oklch_h" ], header: { ink_name_japanese: "日本語", ink_name: "Ink", description: "Meaning", hex: "Hex", hcl_h: "HCL Hue", oklch_h: "OKLCH Hue" }})This tiny handoff was one of the things I really wanted to understand from this experiment.R prepares the data. Observable gets to play with it in the browser.First Observable experiment: rearrange the inksI’m starting with something very simple.Two controls:Which colour space?Codeviewof colour_space = Inputs.radio( ["HCL", "OKLCH"], { label: "Colour space", value: "OKLCH" })And:What should I sort by?Codeviewof arrange_by = Inputs.radio( ["Hue", "Chroma", "Lightness"], { label: "Arrange inks by", value: "Hue" })Because Observable is reactive, changing either input automatically changes anything that depends on it.I’ll first map the selected options to the appropriate data column.Codesort_field = { if (colour_space === "HCL") { if (arrange_by === "Hue") return "hcl_h"; if (arrange_by === "Chroma") return "hcl_c"; return "hcl_l"; } if (arrange_by === "Hue") return "oklch_h"; if (arrange_by === "Chroma") return "oklch_c"; return "oklch_l";}And then sort.For hue I want low → high around the colour wheel. For chroma and lightness, I find high → low slightly easier to read.Codesorted_inks = { const rows = [...ink_rows]; if (arrange_by === "Hue") { return rows.sort((a, b) => a[sort_field] - b[sort_field]); } return rows.sort((a, b) => b[sort_field] - a[sort_field]);}The interactive paletteFor my first Observable visualization, I’m deliberately keeping the geometry boring.Each ink is just a coloured tile.The interesting part is that its position is reactive.Codeink_grid = sorted_inks.map((d, i) => ({ ...d, column: i % 6, row: 3 - Math.floor(i / 6)}))CodePlot.plot({ width: 850, height: 420, marginTop: 20, marginRight: 20, marginBottom: 20, marginLeft: 20, x: { axis: null, domain: d3.range(6) }, y: { axis: null, domain: d3.range(4) }, marks: [ Plot.cell(ink_grid, { x: "column", y: "row", fill: "hex", inset: 3, tip: true, title: d => { const prefix = colour_space === "HCL" ? "hcl" : "oklch"; return `${d.ink_name_japanese} · ${d.ink_name}${d.description}${d.hex}${colour_space}Hue ${d[`${prefix}_h`].toFixed(1)}°Chroma ${d[`${prefix}_c`].toFixed(2)}Lightness ${d[`${prefix}_l`].toFixed(2)}`; } }), Plot.text(ink_grid, { x: "column", y: "row", text: "ink_name_japanese", fill: "white", fontSize: 18, dy: -5 }), Plot.text(ink_grid, { x: "column", y: "row", text: "ink_name", fill: "white", fontSize: 11, dy: 14 }) ]})Try changing both controls.Same 24 inks.Same hex colours.Different representation, different ordering.Put the inks into colour spaceSorting is one way to use the coordinates.But I can also stop treating the shelf position as meaningful at all and let the colour coordinates determine where each ink goes.First I’ll create generic hue and chroma values based on whichever colour space is selected.Codecolour_space_inks = ink_rows.map(d => ({ ...d, display_h: colour_space === "HCL" ? d.hcl_h : d.oklch_h, display_c: colour_space === "HCL" ? d.hcl_c : d.oklch_c, display_l: colour_space === "HCL" ? d.hcl_l : d.oklch_l}))Codemax_chroma = d3.max(colour_space_inks, d => d.display_c)polar_inks = colour_space_inks.map(d => { const theta = (d.display_h - 90) * Math.PI / 180; // scale chroma to a plotting radius const r = (d.display_c / max_chroma) * 85; return { ...d, theta, radius_value: r, polar_x: r * Math.cos(theta), polar_y: r * Math.sin(theta) };})lightnessExtent = d3.extent(polar_inks, d => d.display_l)lightnessScale = d3.scaleLinear() .domain(lightnessExtent) .range([20, 58])Now the same plot can switch between HCL and OKLCH.CodePlot.plot({ width: 850, height: 500, x: { label: `${colour_space} Hue →`, domain: [0, 360] }, y: { label: `↑ ${colour_space} Chroma`, grid: true }, marks: [ Plot.dot(colour_space_inks, { x: "display_h", y: "display_c", fill: "hex", r: 20, stroke: "white", strokeWidth: 1.5, tip: true, title: d => `${d.ink_name_japanese} · ${d.ink_name}${d.description}H ${d.display_h.toFixed(1)}°C ${d.display_c.toFixed(2)}L ${d.display_l.toFixed(2)}` }) ]})CodePlot.plot({ width: 700, height: 700, margin: 40, aspectRatio: 1, x: { axis: null }, y: { axis: null }, r: { range: [12,30] }, marks: [ Plot.frame(), // faint reference rings Plot.circle( [20, 40, 60, 80], { x: 0, y: 0, r: d => d, stroke: "#d9d9d9", fill: null } ), // crosshair guides Plot.ruleX([0], {stroke: "#dddddd"}), Plot.ruleY([0], {stroke: "#dddddd"}), // labels for cardinal hue directions Plot.text( [ {x: 0, y: 95, label: "0°"}, {x: 95, y: 0, label: "90°"}, {x: 0, y: -95, label: "180°"}, {x: -95, y: 0, label: "270°"} ], { x: "x", y: "y", text: "label", fontSize: 11, fill: "#777" } ), Plot.dot(polar_inks, { x: "polar_x", y: "polar_y", fill: "hex", stroke: "white", strokeWidth: 1.5, // use lightness for dot size r: d => lightnessScale(d.display_l), //r: 25, tip: true, title: d => `${d.ink_name_japanese} · ${d.ink_name}${d.description}${colour_space}Hue ${d.display_h.toFixed(1)}°Chroma ${d.display_c.toFixed(2)}Lightness ${d.display_l.toFixed(2)}` }) ]})Now the colour-space control changes the coordinate system itself, not just the order of the tiles.That is much more fun.One important caveat: I shouldn’t interpret the numeric scales of HCL chroma and OKLCH chroma as though they were directly comparable. What interests me here is the resulting relative structure of the 24 colours.What I learned just getting this farThis is my first time putting Observable JS directly inside a Quarto document, and the biggest adjustment so far is that it doesn’t feel like writing another sequence of notebook cells.There are a few different things happening:R prepares the dataset.Quarto passes it into the page.Observable handles reactive values and dependencies.Observable Plot draws the browser-side visualization.Once that clicked, this started to feel much less mysterious.And I really like the idea that I can keep doing data preparation in R while using JavaScript only for the parts where browser interaction is actually useful. To leave a comment for the author, please follow the link and comment on their blog: CHI(χ)-Files.R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.Continue reading: Making Iroshizuku Interactive with Observable