From 3764eb1527efc79b83dfb625c4ace15cf7197957 Mon Sep 17 00:00:00 2001 From: Istvan Bartha <106101+pityka@users.noreply.github.com> Date: Mon, 11 May 2026 23:58:10 +0200 Subject: [PATCH] fix canvas rendering + tests --- awt/src/main/scala/org/nspl/awt.scala | 44 +- awt/src/test/scala/interaction.test.scala | 135 ++++++ awt/src/test/scala/render.test.scala | 132 ++++++ awt/src/test/scala/text.test.scala | 190 ++++++++ build.sbt | 3 +- canvas/index.html | 81 +++- canvas/src/main/scala/org/nspl/canvas.scala | 421 +++++++++++++++--- canvas/src/test/scala/test.scala | 211 ++++----- core/src/main/scala/org/nspl/axis.scala | 81 +++- core/src/main/scala/org/nspl/color.scala | 17 + core/src/main/scala/org/nspl/core.scala | 51 ++- .../org/nspl/data/SparseDataMatrix.scala | 107 +++++ .../main/scala/org/nspl/data/histogram.scala | 38 +- .../main/scala/org/nspl/datarenderers.scala | 196 +++++--- core/src/main/scala/org/nspl/elements.scala | 71 ++- core/src/main/scala/org/nspl/events.scala | 51 +++ core/src/main/scala/org/nspl/plot.scala | 108 ++++- core/src/test/scala/org/nspl/axis.test.scala | 72 +++ .../src/test/scala/org/nspl/bounds.test.scala | 52 +++ .../src/test/scala/org/nspl/events.test.scala | 96 ++++ .../test/scala/org/nspl/histogram.test.scala | 58 +++ project/metals.sbt | 2 +- shared-js/src/main/scala/org/nspl/font.scala | 11 +- 23 files changed, 1931 insertions(+), 297 deletions(-) create mode 100644 awt/src/test/scala/interaction.test.scala create mode 100644 awt/src/test/scala/render.test.scala create mode 100644 awt/src/test/scala/text.test.scala create mode 100644 core/src/main/scala/org/nspl/data/SparseDataMatrix.scala create mode 100644 core/src/test/scala/org/nspl/axis.test.scala create mode 100644 core/src/test/scala/org/nspl/bounds.test.scala create mode 100644 core/src/test/scala/org/nspl/events.test.scala create mode 100644 core/src/test/scala/org/nspl/histogram.test.scala diff --git a/awt/src/main/scala/org/nspl/awt.scala b/awt/src/main/scala/org/nspl/awt.scala index 4bb61a06..5a00f3af 100644 --- a/awt/src/main/scala/org/nspl/awt.scala +++ b/awt/src/main/scala/org/nspl/awt.scala @@ -1,6 +1,7 @@ package org.nspl import java.awt.Graphics2D +import java.awt.font.TextAttribute import JavaFontConversion._ @@ -101,11 +102,52 @@ object awtrenderer extends JavaAWTUtil { implicit val textRenderer: Renderer[TextBox, JavaRC] = new Renderer[TextBox, JavaRC] { + private def deriveFont( + base: java.awt.Font, + bold: Boolean, + oblique: Boolean, + subScript: Boolean, + superScript: Boolean, + underline: Boolean + ): java.awt.Font = { + if (!bold && !oblique && !subScript && !superScript && !underline) base + else { + val map = + new java.util.HashMap[ + java.awt.font.TextAttribute, + java.lang.Object + ]() + if (bold) { + map.put(TextAttribute.WEIGHT, TextAttribute.WEIGHT_EXTRABOLD) + } + if (oblique) { + map.put(TextAttribute.POSTURE, TextAttribute.POSTURE_OBLIQUE) + } + if (underline) { + map.put(TextAttribute.UNDERLINE, TextAttribute.UNDERLINE_ON) + } + if (subScript && !superScript) { + map.put(TextAttribute.SUPERSCRIPT, TextAttribute.SUPERSCRIPT_SUB) + } else if (superScript && !subScript) { + map.put(TextAttribute.SUPERSCRIPT, TextAttribute.SUPERSCRIPT_SUPER) + } + base.deriveFont(map) + } + } + def render(ctx: JavaRC, elem: TextBox): Unit = { if (!elem.layout.isEmpty && elem.color.a > 0) { ctx.withTransform(elem.tx) { ctx.withPaint(elem.color) { - val jfont = font2font(elem.font) + val baseFont = font2font(elem.font) + val jfont = deriveFont( + baseFont, + elem.bold, + elem.oblique, + elem.subScript, + elem.superScript, + elem.underline + ) if (!ctx.textAsShapes) { ctx.graphics.setFont(jfont) } diff --git a/awt/src/test/scala/interaction.test.scala b/awt/src/test/scala/interaction.test.scala new file mode 100644 index 00000000..78cc2cbf --- /dev/null +++ b/awt/src/test/scala/interaction.test.scala @@ -0,0 +1,135 @@ +package org.nspl + +import org.nspl.awtrenderer._ + +class InteractionSpec extends munit.FunSuite { + + /** Build a square xyplot covering the world rectangle (0..10, 0..10). */ + private def squarePlot(): (XYPlotArea, Build[XYPlotArea]) = { + val data = dataSourceFromRows( + Seq((0d, 0d), (5d, 5d), (10d, 10d)) + ) + val build = xyplotareaBuild( + List(data -> List(point(noIdentifier = true))), + AxisSettings(LinearAxisFactory), + AxisSettings(LinearAxisFactory), + xlim = Some(0d -> 10d), + ylim = Some(0d -> 10d), + // disable padding margins so xMin/xMax = 0/10 exactly + xAxisMargin = 0d, + yAxisMargin = 0d + ) + (build.build, build) + } + + test("Selection zooms to the world rectangle under the selection box") { + val (initial, build) = squarePlot() + assertEqualsDouble(initial.xMin, 0d, 0d) + assertEqualsDouble(initial.xMax, 10d, 0d) + assertEqualsDouble(initial.yMin, 0d, 0d) + assertEqualsDouble(initial.yMax, 10d, 0d) + + val plotAreaId = initial.frameElem.identifier match { + case p: PlotAreaIdentifier => p + case other => fail(s"frame elem had identifier $other") + } + // Pretend the plot area was rendered in a 100x100 screen rectangle. + val withBounds = plotAreaId.copy(bounds = Some(Bounds(0, 0, 100, 100))) + + val sel = Selection(Point(25, 25), Point(75, 75), withBounds) + val next = build((Some(initial), sel)) + + assertEqualsDouble(next.xMin, 2.5, 1e-9) + assertEqualsDouble(next.xMax, 7.5, 1e-9) + assertEqualsDouble(next.yMin, 2.5, 1e-9) + assertEqualsDouble(next.yMax, 7.5, 1e-9) + } + + test("Selection corners may be specified in either order") { + val (initial, build) = squarePlot() + val plotAreaId = initial.frameElem.identifier + .asInstanceOf[PlotAreaIdentifier] + .copy(bounds = Some(Bounds(0, 0, 100, 100))) + val sel = Selection(Point(75, 75), Point(25, 25), plotAreaId) + val next = build((Some(initial), sel)) + assertEqualsDouble(next.xMin, 2.5, 1e-9) + assertEqualsDouble(next.xMax, 7.5, 1e-9) + assertEqualsDouble(next.yMin, 2.5, 1e-9) + assertEqualsDouble(next.yMax, 7.5, 1e-9) + } + + test("Selection on a different plot area is ignored") { + val (initial, build) = squarePlot() + val otherId = PlotAreaIdentifier(new PlotId, Some(Bounds(0, 0, 100, 100))) + val sel = Selection(Point(25, 25), Point(75, 75), otherId) + val next = build((Some(initial), sel)) + // unchanged + assertEqualsDouble(next.xMin, initial.xMin, 0d) + assertEqualsDouble(next.xMax, initial.xMax, 0d) + assertEqualsDouble(next.yMin, initial.yMin, 0d) + assertEqualsDouble(next.yMax, initial.yMax, 0d) + } + + test("Degenerate (single-point) Selection is rejected") { + val (initial, build) = squarePlot() + val plotAreaId = initial.frameElem.identifier + .asInstanceOf[PlotAreaIdentifier] + .copy(bounds = Some(Bounds(0, 0, 100, 100))) + val sel = Selection(Point(50, 50), Point(50, 50), plotAreaId) + val next = build((Some(initial), sel)) + assertEqualsDouble(next.xMin, initial.xMin, 0d) + assertEqualsDouble(next.xMax, initial.xMax, 0d) + } + + test("Scroll event zooms (negative delta in, positive delta out)") { + val (initial, build) = squarePlot() + val plotAreaId = initial.frameElem.identifier + .asInstanceOf[PlotAreaIdentifier] + .copy(bounds = Some(Bounds(0, 0, 100, 100))) + val before = initial.xMax - initial.xMin + + val zin = build((Some(initial), Scroll(-1d, Point(50, 50), plotAreaId))) + val zout = build((Some(initial), Scroll(1d, Point(50, 50), plotAreaId))) + + assert( + zin.xMax - zin.xMin < before, + s"expected zoom-in: range=${zin.xMax - zin.xMin} >= $before" + ) + assert( + zout.xMax - zout.xMin > before, + s"expected zoom-out: range=${zout.xMax - zout.xMin} <= $before" + ) + } + + test("ShapeElem.withIdentifier round-trips") { + val id = DataRowIdx(0, 0, 7) + val s = ShapeElem(Shape.circle(1d)).withIdentifier(id) + assertEquals(s.identifier, id: Identifier) + } + + test("TextBox.withIdentifier and apply identifier round-trip") { + val id = TextBoxIdentifier("legend", 3) + val t1 = TextBox("hi").withIdentifier(id) + val t2 = TextBox("hi", identifier = id) + assertEquals(t1.identifier, id: Identifier) + assertEquals(t2.identifier, id: Identifier) + } + + test("DataRowIdx and TextBoxIdentifier are distinct types") { + val data: Identifier = DataRowIdx(1, 2, 3) + val text: Identifier = TextBoxIdentifier("a", 0) + // Compile-time test: pattern matching disambiguates. + val dataKind = data match { + case _: DataRowIdx => "data" + case _: TextBoxIdentifier => "text" + case _ => "other" + } + val textKind = text match { + case _: DataRowIdx => "data" + case _: TextBoxIdentifier => "text" + case _ => "other" + } + assertEquals(dataKind, "data") + assertEquals(textKind, "text") + } +} diff --git a/awt/src/test/scala/render.test.scala b/awt/src/test/scala/render.test.scala new file mode 100644 index 00000000..30f147ea --- /dev/null +++ b/awt/src/test/scala/render.test.scala @@ -0,0 +1,132 @@ +package org.nspl + +import org.nspl.awtrenderer._ +import org.nspl.data._ +import java.awt.image.BufferedImage +import java.awt.{Color => AwtColor, RenderingHints} + +class RenderSmokeSpec extends munit.FunSuite { + + private def renderToImage[K <: Renderable[K]]( + elem: K, + width: Int = 400 + )(implicit er: Renderer[K, JavaRC]): BufferedImage = { + val aspect = elem.bounds.h / elem.bounds.w + val height = math.max((width * aspect).toInt, 1) + val img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val g2d = img.createGraphics() + g2d.setRenderingHint( + RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON + ) + g2d.setRenderingHint( + RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON + ) + g2d.setColor(AwtColor.WHITE) + g2d.fillRect(0, 0, width, height) + val rc = new JavaRC(g2d, doRender = true) + rc.render(fitToBounds(elem, Bounds(0, 0, width, height))) + img + } + + private def hasInk(img: BufferedImage): Boolean = { + val w = img.getWidth + val h = img.getHeight + var y = 0 + while (y < h) { + var x = 0 + while (x < w) { + val rgb = img.getRGB(x, y) + val r = (rgb >>> 16) & 0xff + val g = (rgb >>> 8) & 0xff + val b = rgb & 0xff + if (r < 240 || g < 240 || b < 240) return true + x += 1 + } + y += 1 + } + false + } + + test("scatter plot renders without error and produces non-blank output") { + val data = (1 to 100).map(i => (i.toDouble, i.toDouble * 0.5)) + val p = xyplot(data -> point())().build + val img = renderToImage(p) + assert(hasInk(img), "rendered image is blank") + assertEquals(img.getWidth, 400) + } + + test("line plot renders without error") { + val data = (1 to 50).map(i => (i.toDouble, math.sin(i * 0.2))) + val p = xyplot(data -> line())().build + assert(hasInk(renderToImage(p))) + } + + test("bar plot on a log y-axis renders (regression: would crash on worldToView(0))") { + val data = (1 to 5).map(i => (i.toDouble, math.pow(10d, i.toDouble))) + val p = xyplot( + data -> bar(width = 0.6) + )(par.ylog(true)).build + // Just exercise the render — the bug it guards against was a + // RuntimeException("<0") from log10(0) in bar()'s width calculation. + val img = renderToImage(p) + assert(hasInk(img)) + } + + test("error bars actually pick up the errorBarColor (regression for fill/stroke bug)") { + // Magenta error bars should appear as magenta-ish pixels in the + // output. The bug was that errorBarColor was being passed as `fill` + // on a stroked line (which is invisible), so the bars always rendered + // black. We just verify some pixels carry the requested color. + val data = Seq( + (1d, 5d, 0d, 0d, 0d, 7d, 3d), // x, y, color, size, shape, errTop, errBot + (2d, 5d, 0d, 0d, 0d, 7d, 3d), + (3d, 5d, 0d, 0d, 0d, 7d, 3d) + ) + val magenta = Color(255, 0, 255, 255) + val p = xyplot( + data -> point( + size = 10d, + color = Color.black, + errorBarColor = magenta, + errorBarStroke = StrokeConf(0.5 fts) + ) + )(par.xlim(Some(0d -> 4d)).ylim(Some(0d -> 10d))).build + val img = renderToImage(p, width = 600) + // Look for any pixel where R is much higher than G — magenta. + var found = false + val w = img.getWidth + val h = img.getHeight + var y = 0 + while (y < h && !found) { + var x = 0 + while (x < w && !found) { + val rgb = img.getRGB(x, y) + val r = (rgb >>> 16) & 0xff + val gg = (rgb >>> 8) & 0xff + val bb = rgb & 0xff + if (r > 150 && bb > 150 && gg < 100) found = true + x += 1 + } + y += 1 + } + assert(found, "no magenta error-bar pixels were rendered") + } + + test("rendered image dimensions are sane for an extreme aspect ratio") { + // Long narrow data shouldn't produce a zero-height image. + val data = (1 to 200).map(i => (i.toDouble, math.sin(i * 0.1) * 0.001)) + val p = xyplot(data -> line())().build + val img = renderToImage(p, width = 800) + assert(img.getHeight > 0, s"got height ${img.getHeight}") + } + + test("histogram of constant data does not divide by zero") { + // Regression check for histogram code paths. + val hist = HistogramData(Seq.fill(20)(3.5), 1.0) + val d = hist.density + // Should not throw and should return *some* HistogramData. + assert(d.bins.nonEmpty || hist.bins.isEmpty) + } +} diff --git a/awt/src/test/scala/text.test.scala b/awt/src/test/scala/text.test.scala new file mode 100644 index 00000000..c4f0b93d --- /dev/null +++ b/awt/src/test/scala/text.test.scala @@ -0,0 +1,190 @@ +package org.nspl + +import org.nspl.awtrenderer._ +import java.awt.image.BufferedImage +import java.awt.{Color => AwtColor, RenderingHints} + +class TextBoxBoundsSpec extends munit.FunSuite { + + private val width = 600 + private val height = 200 + + private def mkContext(): (BufferedImage, JavaRC) = { + val img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val g2d = img.createGraphics() + g2d.setRenderingHint( + RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON + ) + g2d.setRenderingHint( + RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON + ) + g2d.setColor(AwtColor.WHITE) + g2d.fillRect(0, 0, width, height) + g2d.setColor(AwtColor.BLACK) + val ctx = new JavaRC(g2d, doRender = true) + (img, ctx) + } + + /** Bounding box of non-white pixels in `img`, or `None` if the image is + * fully white. + */ + private def inkBounds(img: BufferedImage): Option[Bounds] = { + var minX = Int.MaxValue + var minY = Int.MaxValue + var maxX = Int.MinValue + var maxY = Int.MinValue + val w = img.getWidth + val h = img.getHeight + var y = 0 + while (y < h) { + var x = 0 + while (x < w) { + val argb = img.getRGB(x, y) + val a = (argb >>> 24) & 0xff + val r = (argb >>> 16) & 0xff + val g = (argb >>> 8) & 0xff + val b = argb & 0xff + // Treat anything visibly darker than near-white as ink. Stay above + // the antialias fringe; we want a stable ink-bbox. + val isInk = a > 16 && (r < 200 || g < 200 || b < 200) + if (isInk) { + if (x < minX) minX = x + if (y < minY) minY = y + if (x > maxX) maxX = x + if (y > maxY) maxY = y + } + x += 1 + } + y += 1 + } + if (minX > maxX) None + else Some(Bounds(minX.toDouble, minY.toDouble, (maxX - minX).toDouble, (maxY - minY).toDouble)) + } + + /** True if `inner` is fully inside `outer` with a tolerance of `slack` + * pixels on every side. */ + private def encloses(outer: Bounds, inner: Bounds, slack: Double): Boolean = + inner.x >= outer.x - slack && + inner.y >= outer.y - slack && + inner.maxX <= outer.maxX + slack && + inner.maxY <= outer.maxY + slack + + test("TextBox.bounds encloses the rendered ink (plain text)") { + val (img, ctx) = mkContext() + val tb = TextBox("Hello world", fontSize = 2 fts) + textRenderer.render(ctx, tb) + val ink = inkBounds(img).getOrElse(fail("nothing was rendered")) + val predicted = tb.bounds + // Most fonts have advance widths > glyph ink widths (italic exceeds on + // the right by the slant); accept a few pixels of slack on each side. + assert( + encloses(predicted, ink, slack = 4), + s"predicted=$predicted does not enclose ink=$ink" + ) + // Sanity: ink is non-degenerate. + assert(ink.w > 10, s"ink too narrow: $ink") + assert(ink.h > 5, s"ink too short: $ink") + } + + test("TextBox.bounds has positive width / height for nontrivial text") { + val tb = TextBox("Hello world", fontSize = 1.5 fts) + assert(tb.bounds.w > 0, s"width was ${tb.bounds.w}") + assert(tb.bounds.h > 0, s"height was ${tb.bounds.h}") + } + + test("empty TextBox has zero bounds and renders nothing") { + val (img, ctx) = mkContext() + val tb = TextBox("") + assertEquals(tb.bounds, Bounds(0, 0, 0, 0)) + textRenderer.render(ctx, tb) + assertEquals(inkBounds(img), None) + } + + test("bold ink is at least as wide horizontally as plain ink") { + val (imgP, ctxP) = mkContext() + val (imgB, ctxB) = mkContext() + val plain = TextBox("Hello", fontSize = 3 fts) + val bold = TextBox("Hello", fontSize = 3 fts, bold = true) + textRenderer.render(ctxP, plain) + textRenderer.render(ctxB, bold) + val ip = inkBounds(imgP).get + val ib = inkBounds(imgB).get + // Bold glyphs have thicker strokes — ink mass differs even if the + // advance widths are the same. We test the simplest invariant: bold + // ink is not narrower than plain ink minus a small antialiasing + // wiggle. + assert(ib.w + 2 >= ip.w, s"bold width ${ib.w} unexpectedly narrower than plain ${ip.w}") + } + + test("oblique ink extends to the right of plain ink") { + val (imgP, ctxP) = mkContext() + val (imgO, ctxO) = mkContext() + val plain = TextBox("M", fontSize = 4 fts) + val obl = TextBox("M", fontSize = 4 fts, oblique = true) + textRenderer.render(ctxP, plain) + textRenderer.render(ctxO, obl) + val ip = inkBounds(imgP).get + val io = inkBounds(imgO).get + // Italic slant pushes the top-right of the glyph past the plain + // glyph's right edge. Allow a 1-pixel wiggle. + assert(io.maxX + 1 >= ip.maxX, s"oblique maxX ${io.maxX} not ≥ plain maxX ${ip.maxX}") + } + + test("underline adds ink below the glyph baseline that plain text lacks") { + val (imgP, ctxP) = mkContext() + val (imgU, ctxU) = mkContext() + val plain = TextBox("text", fontSize = 4 fts) + val under = TextBox("text", fontSize = 4 fts, underline = true) + textRenderer.render(ctxP, plain) + textRenderer.render(ctxU, under) + val ip = inkBounds(imgP).get + val iu = inkBounds(imgU).get + // Underlined ink reaches further down (descender region) than plain. + assert( + iu.maxY > ip.maxY, + s"underlined maxY ${iu.maxY} should exceed plain maxY ${ip.maxY}" + ) + } + + test("superscript ink sits above subscript ink for the same string") { + val (imgSup, ctxSup) = mkContext() + val (imgSub, ctxSub) = mkContext() + val sup = TextBox("x", fontSize = 4 fts, superScript = true) + val sub = TextBox("x", fontSize = 4 fts, subScript = true) + textRenderer.render(ctxSup, sup) + textRenderer.render(ctxSub, sub) + val isup = inkBounds(imgSup).get + val isub = inkBounds(imgSub).get + // The centroid (or just the centerY) of the superscript glyph must + // sit strictly above the centroid of the subscript glyph. + val supCenter = (isup.y + isup.maxY) * 0.5 + val subCenter = (isub.y + isub.maxY) * 0.5 + assert( + supCenter < subCenter, + s"super centerY $supCenter not above sub centerY $subCenter" + ) + } + + test("multi-line wrapped text bounds enclose the ink") { + val (img, ctx) = mkContext() + val tb = TextBox( + "the quick brown fox jumps over the lazy dog", + width = Some(60d), + fontSize = 1.5 fts + ) + textRenderer.render(ctx, tb) + val ink = inkBounds(img).getOrElse(fail("nothing rendered")) + assert( + encloses(tb.bounds, ink, slack = 4), + s"wrapped: predicted=${tb.bounds} did not enclose ink=$ink" + ) + // Wrapped onto more than one line, so height > one-line height. + val oneLine = TextBox("the", fontSize = 1.5 fts) + assert( + tb.bounds.h > oneLine.bounds.h * 1.5, + s"wrapped height ${tb.bounds.h} should be > 1.5 * single line ${oneLine.bounds.h}" + ) + } +} diff --git a/build.sbt b/build.sbt index 9bc587db..281e506e 100644 --- a/build.sbt +++ b/build.sbt @@ -90,7 +90,8 @@ lazy val core = project .in(file("core")) .settings(commonSettings) .settings( - name := "nspl-core" + name := "nspl-core", + libraryDependencies += "org.scalameta" %% "munit" % "1.0.0" % Test ) .enablePlugins(spray.boilerplate.BoilerplatePlugin) diff --git a/canvas/index.html b/canvas/index.html index 908ddefc..82f9ade3 100644 --- a/canvas/index.html +++ b/canvas/index.html @@ -1,17 +1,86 @@ - + + + nspl canvas interaction smoke test + - + -
+

nspl canvas interaction smoke test

- +
+ Drive the plot to verify each interactive surface: + + The line plot below the scatter only handles drag + wheel + plot-area mousedown. +
+ +
+

Scatter — full interaction

+
+
+ +
+

Line — pan / zoom only

+
+
+ +

Event log (newest first)

+

+
+  
   
 
 
-
\ No newline at end of file
+
diff --git a/canvas/src/main/scala/org/nspl/canvas.scala b/canvas/src/main/scala/org/nspl/canvas.scala
index 7dc61e4b..8f13d0aa 100644
--- a/canvas/src/main/scala/org/nspl/canvas.scala
+++ b/canvas/src/main/scala/org/nspl/canvas.scala
@@ -1,6 +1,8 @@
 package org.nspl
 
-import org.scalajs.dom._
+// Exclude `Event` and `Selection` from the wildcard import — they would
+// shadow our `org.nspl.Event` and `org.nspl.Selection` under Scala 3.
+import org.scalajs.dom.{Event => _, Selection => _, _}
 import org.scalajs.dom
 import org.scalajs.dom.html
 import scala.collection.mutable.ArrayBuffer
@@ -23,9 +25,17 @@ private[nspl] class RunningAvg {
   def currentN = n
 }
 
+/** Rendering context for the HTML5 canvas backend. The optional callbacks
+  * are wired up by [[canvasrenderer.render]]; user code constructs instances
+  * indirectly through that entry point.
+  */
 class CanvasRC private[nspl] (
     private[nspl] val graphics: CanvasRenderingContext2D,
-    private[nspl] val cick: Identifier => Unit
+    private[nspl] val cick: Identifier => Unit,
+    private[nspl] val onHover: Option[(Identifier, MouseEvent) => Unit],
+    private[nspl] val onUnhover: Option[(Identifier, MouseEvent) => Unit],
+    private[nspl] val onShapeClick: Option[(Identifier, MouseEvent) => Unit],
+    private[nspl] val onSelection: Option[collection.Seq[Identifier] => Unit]
 ) extends RenderingContext[CanvasRC] {
 
   private[nspl] var transform: AffineTransform = AffineTransform.identity
@@ -89,11 +99,23 @@ class CanvasRC private[nspl] (
   def getTransform: LocalTx = transform
 
   private[nspl] var mousedown = false
+  private[nspl] var dragShift = false
   private[nspl] val plotAreaShapes = ArrayBuffer[(Shape, PlotAreaIdentifier)]()
+  private[nspl] val clickShapes = ArrayBuffer[(Shape, Identifier)]()
+  private[nspl] val hoverShapes = ArrayBuffer[(Shape, Identifier)]()
+  private[nspl] val selectionShapes = ArrayBuffer[(Shape, Identifier)]()
+  private[nspl] var lastHovered: collection.Seq[Int] = Vector.empty
 
   private[nspl] def registerPlotArea(shape: Shape, id: PlotAreaIdentifier) =
     plotAreaShapes.append((shape, id))
 
+  private[nspl] def registerOnClick(shape: Shape, id: Identifier) =
+    clickShapes.append((shape, id))
+  private[nspl] def registerOnHover(shape: Shape, id: Identifier) =
+    hoverShapes.append((shape, id))
+  private[nspl] def registerOnSelection(shape: Shape, id: Identifier) =
+    selectionShapes.append((shape, id))
+
   private[nspl] def processPlotArea(p: Point)(
       cb: PlotAreaIdentifier => Unit
   ) = {
@@ -105,50 +127,97 @@ class CanvasRC private[nspl] (
     )
   }
 
+  /** Hit-test the registered hover shapes at point `p`, invoke `onHover` for
+    * each hit, and invoke `onUnhover` for shapes that were hit on the
+    * previous call but are not hit this call.
+    */
+  private[nspl] def callHoverCallbackOnHit(e: MouseEvent, p: Point): Unit =
+    onHover.foreach { hover =>
+      val previous = lastHovered
+      val current = hitTest[Identifier](
+        p,
+        false,
+        hoverShapes,
+        (id, _) => hover(id, e)
+      )
+      lastHovered = current
+      val gone = previous.filterNot(current.contains)
+      onUnhover.foreach { unhover =>
+        gone.foreach { idx =>
+          if (idx >= 0 && idx < hoverShapes.size)
+            unhover(hoverShapes(idx)._2, e)
+        }
+      }
+    }
+
+  private[nspl] def callClickCallbackOnHit(e: MouseEvent, p: Point): Unit =
+    onShapeClick.foreach { click =>
+      hitTest[Identifier](
+        p,
+        false,
+        clickShapes,
+        (id, _) => click(id, e)
+      )
+    }
+
+  private[nspl] def callSelectionCallback(p1: Point, p2: Point): Unit =
+    onSelection.foreach { sel =>
+      val rect = Bounds.fromPoints(p1, p2)
+      val hits = selectionShapes
+        .filter { case (sh, _) =>
+          val b = sh.bounds
+          rect.contains(Point(b.centerX, b.centerY))
+        }
+        .map(_._2)
+      sel(hits)
+    }
+
+  /** Iterate shapes, hit-test each, fire callback on hits, return the
+    * indices of shapes that were hit. Indices are useful so the caller
+    * can compare against a previous call's hit set (for unhover).
+    */
   private def hitTest[T](
       p: Point,
       needsTransformedBounds: Boolean,
       shapes: collection.Seq[(Shape, T)],
       callback: (T, Option[Bounds]) => Unit
-  ) = {
+  ): collection.Seq[Int] = {
     val ctx = graphics
-
-    shapes.foreach { case (shape, id) =>
+    val out = ArrayBuffer.empty[Int]
+    var idx = 0
+    val n = shapes.size
+    while (idx < n) {
+      val (shape, id) = shapes(idx)
       val (hit, transformedBounds) = withTransform(shape.currentTransform) {
         setTransformInGraphics()
-        shape match {
-          case Rectangle(x, y, w, h, _, _) =>
-            ctx.beginPath()
-            ctx.rect(x, y, w, h)
-            val r =
-              ctx.isPointInPath(p.x, p.y)
-
-            val transformedBounds =
-              if (needsTransformedBounds)
-                id match {
-                  case pl: PlotAreaIdentifier =>
-                    pl.bounds.map(transform.transform)
-                  case _ => None
-                }
-              else None
-            r -> transformedBounds
-
-          case _ => ???
-        }
-
+        val tb =
+          if (needsTransformedBounds)
+            id match {
+              case pl: PlotAreaIdentifier =>
+                pl.bounds.map(transform.transform)
+              case _ => None
+            }
+          else None
+        val isHit = canvasrenderer.shapeContains(ctx, shape, p)
+        (isHit, tb)
       }
-
       if (hit) {
         callback(id, transformedBounds)
+        out.append(idx)
       }
-
+      idx += 1
     }
+    out
   }
 
   def clear() = {
     graphics.setTransform(1, 0, 0, 1, 0, 0)
     transformInGraphics = AffineTransform.identity
     graphics.clearRect(0, 0, graphics.canvas.width, graphics.canvas.height)
+    plotAreaShapes.clear()
+    clickShapes.clear()
+    hoverShapes.clear()
+    selectionShapes.clear()
   }
 
 }
@@ -175,14 +244,113 @@ object canvasrenderer {
     Point(x * devicePixelRatio, y * devicePixelRatio)
   }
 
+  /** Hit-test using the 2D canvas Path API. Builds the path matching the
+    * shape's geometry then calls `isPointInPath`. Supports every Shape
+    * subtype — previously only Rectangle was handled and the rest fell
+    * into `???`.
+    */
+  private[nspl] def shapeContains(
+      ctx: CanvasRenderingContext2D,
+      shape: Shape,
+      p: Point
+  ): Boolean = shape match {
+    case Rectangle(x, y, w, h, _, _) =>
+      ctx.beginPath()
+      ctx.rect(x, y, w, h)
+      ctx.isPointInPath(p.x, p.y)
+    case Ellipse(x, y, w, h, _) =>
+      val cx = x + 0.5 * w
+      val cy = y + 0.5 * h
+      val rx = 0.5 * w
+      val ry = 0.5 * h
+      ctx.beginPath()
+      ctx
+        .asInstanceOf[scala.scalajs.js.Dynamic]
+        .ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI)
+      ctx.isPointInPath(p.x, p.y)
+    case ln: Line =>
+      // A 1D line in the path API has zero area; treat as containing the
+      // point if it is within a small tolerance of the segment.
+      val dx = ln.x2 - ln.x1
+      val dy = ln.y2 - ln.y1
+      val len2 = dx * dx + dy * dy
+      if (len2 == 0d) {
+        val ex = p.x - ln.x1
+        val ey = p.y - ln.y1
+        ex * ex + ey * ey <= 4d
+      } else {
+        val t =
+          ((p.x - ln.x1) * dx + (p.y - ln.y1) * dy) / len2
+        val tc = if (t < 0d) 0d else if (t > 1d) 1d else t
+        val px = ln.x1 + tc * dx
+        val py = ln.y1 + tc * dy
+        val ex = p.x - px
+        val ey = p.y - py
+        ex * ex + ey * ey <= 4d
+      }
+    case sp: SimplePath =>
+      ctx.beginPath()
+      sp.ps.foreach(pt => ctx.lineTo(pt.x, pt.y))
+      ctx.isPointInPath(p.x, p.y)
+    case path: Path =>
+      ctx.beginPath()
+      path.path.foreach {
+        case op: PathOperation.MoveTo => ctx.moveTo(op.p.x, op.p.y)
+        case op: PathOperation.LineTo => ctx.lineTo(op.p.x, op.p.y)
+        case op: PathOperation.QuadTo =>
+          ctx.quadraticCurveTo(op.p1.x, op.p1.y, op.p2.x, op.p2.y)
+        case op: PathOperation.CubicTo =>
+          ctx.bezierCurveTo(
+            op.p1.x,
+            op.p1.y,
+            op.p2.x,
+            op.p2.y,
+            op.p3.x,
+            op.p3.y
+          )
+      }
+      ctx.isPointInPath(p.x, p.y)
+  }
+
+  /** Render a `Build[K]` into a fresh canvas element and return the
+    * element plus an updater function. Optional callbacks let user code
+    * react to hover/click/selection on shapes that were registered with
+    * a non-empty [[Identifier]].
+    *
+    * @param click
+    *   plot-area click callback (fires on mousedown over a plot area).
+    *   Preserved for backward compatibility.
+    * @param onHover
+    *   fires when the mouse moves over any shape carrying a non-empty
+    *   identifier.
+    * @param onUnhover
+    *   fires when a previously-hovered shape is no longer under the mouse.
+    * @param onShapeClick
+    *   fires on click (mouseup without drag) on any shape with a non-empty
+    *   identifier.
+    * @param onSelection
+    *   fires when the user finishes a shift+drag rectangle, receiving the
+    *   identifiers of all shapes whose centers fall in the rectangle.
+    * @param enableScroll
+    *   if false, wheel events are not consumed.
+    * @param enableDrag
+    *   if false, plain drags do not produce Drag events. Shift+drag still
+    *   produces Selection events when `onSelection` is set.
+    */
   def render[K <: Renderable[K]](
       build0: Build[K],
       width: Int,
       height: Int,
-      click: Identifier => Unit = (_ => ())
+      click: Identifier => Unit = (_ => ()),
+      onHover: Option[(Identifier, MouseEvent) => Unit] = None,
+      onUnhover: Option[(Identifier, MouseEvent) => Unit] = None,
+      onShapeClick: Option[(Identifier, MouseEvent) => Unit] = None,
+      onSelection: Option[collection.Seq[Identifier] => Unit] = None,
+      enableScroll: Boolean = true,
+      enableDrag: Boolean = true
   )(implicit
       er: Renderer[K, CanvasRC]
-  ) = {
+  ): (html.Canvas, Build[K] => Unit) = {
 
     val canvas = dom.document.createElement("canvas").asInstanceOf[html.Canvas]
     canvas.style.width = s"${width}px"
@@ -194,22 +362,37 @@ object canvasrenderer {
     val ctx =
       new CanvasRC(
         canvas.getContext("2d").asInstanceOf[dom.CanvasRenderingContext2D],
-        click
+        click,
+        onHover,
+        onUnhover,
+        onShapeClick,
+        onSelection
       )
 
     var build = build0
     var paintableElem = build.build
     var dragStart = Point(0, 0)
     var queuedCallback: Double => Unit = null
+    val eventStore = new EventFusionHelper
 
     def paintBounds = {
-      val aspect = paintableElem.bounds.h / paintableElem.bounds.w
-
-      val paintWidth =
-        if (aspect > 1) (canvas.height / aspect).toInt else canvas.width
-      val paintHeight =
-        if (aspect <= 1) (canvas.width * aspect).toInt else canvas.height
-      Bounds(0, 0, paintWidth, paintHeight)
+      // Fit-inside: scale the plot so it fits entirely within the canvas
+      // without distorting its aspect. The previous version compared the
+      // plot aspect to `1` rather than to the *canvas* aspect, which
+      // overflowed the canvas height when the canvas was wider than tall
+      // (e.g. 600×200) but the plot was still taller than the canvas in
+      // proportion (e.g. plot aspect 0.7 on a canvas aspect 0.33).
+      val plotAspect = paintableElem.bounds.h / paintableElem.bounds.w
+      val canvasAspect = canvas.height.toDouble / canvas.width.toDouble
+      if (plotAspect > canvasAspect) {
+        // plot is taller (relative) than canvas → fit to height
+        val w = (canvas.height / plotAspect).toInt
+        Bounds(0, 0, w, canvas.height)
+      } else {
+        // plot is wider (relative) than canvas → fit to width
+        val h = (canvas.width * plotAspect).toInt
+        Bounds(0, 0, canvas.width, h)
+      }
     }
 
     def queueAnimationFrame(body: Double => Unit) = {
@@ -244,8 +427,6 @@ object canvasrenderer {
 
       ctx.clear()
 
-      ctx.plotAreaShapes.clear()
-
       ctx.render(
         fitToBounds(paintableElem, paintBounds)
       )
@@ -257,26 +438,63 @@ object canvasrenderer {
         val p = getCanvasCoordinate(canvas, e, devicePixelRatio)
         ctx.processPlotArea(p) { identifier =>
           ctx.mousedown = true
+          ctx.dragShift = e.shiftKey
           dragStart = p
           click(identifier)
+        }
+      }
+    }
+
+    def onmouseup(e: MouseEvent) = {
+      if (e.button == 0 && ctx.mousedown) {
+        e.preventDefault()
+        val p = getCanvasCoordinate(canvas, e, devicePixelRatio)
+        if (ctx.dragShift && onSelection.isDefined) {
+          ctx.callSelectionCallback(dragStart, p)
+        }
+      }
+      ctx.mousedown = false
+      ctx.dragShift = false
+    }
 
+    def onclickHandler(e: MouseEvent) = {
+      if (e.button == 0 && onShapeClick.isDefined) {
+        val p = getCanvasCoordinate(canvas, e, devicePixelRatio)
+        dom.window.requestAnimationFrame { _ =>
+          ctx.callClickCallbackOnHit(e, p)
         }
       }
     }
 
     def onmove(e: MouseEvent) = {
+      val p = getCanvasCoordinate(canvas, e, devicePixelRatio)
+      // hover: only when no button is pressed
+      if (e.buttons == 0 && onHover.isDefined) {
+        queueAnimationFrame { _ =>
+          ctx.callHoverCallbackOnHit(e, p)
+        }
+      }
       if (e.button == 0 && ctx.mousedown) {
         e.preventDefault()
         queueAnimationFrame { _ =>
-          val p = getCanvasCoordinate(canvas, e, devicePixelRatio)
           val v = Point(dragStart.x - p.x, dragStart.y - p.y)
           val l = math.sqrt(v.x * v.x + v.y * v.y)
           if (l > 0) {
             ctx.processPlotArea(p) { id =>
-              paintableElem =
-                build(Some(paintableElem) -> Drag(dragStart, p, id))
-              dragStart = p
-              paint()
+              val ev =
+                if (ctx.dragShift && onSelection.isDefined)
+                  Some(Selection(dragStart, p, id))
+                else if (enableDrag)
+                  Some(Drag(dragStart, p, id))
+                else None
+              ev match {
+                case Some(event) =>
+                  paintableElem = build(Some(paintableElem) -> event)
+                  if (!ctx.dragShift) dragStart = p
+                  paint()
+                  eventStore.add(event, id.canFuseEvents)
+                case None => ()
+              }
             }
           }
         }
@@ -284,36 +502,44 @@ object canvasrenderer {
     }
 
     def onwheel(e: MouseEvent) = {
-      e.preventDefault()
-      queueAnimationFrame { _ =>
-        val p = getCanvasCoordinate(canvas, e, devicePixelRatio)
-        ctx.processPlotArea( p) { id =>
-          paintableElem = build(
-            Some(paintableElem) -> Scroll(
+      if (enableScroll) {
+        e.preventDefault()
+        queueAnimationFrame { _ =>
+          val p = getCanvasCoordinate(canvas, e, devicePixelRatio)
+          ctx.processPlotArea(p) { id =>
+            val event = Scroll(
               e.asInstanceOf[scala.scalajs.js.Dynamic]
                 .deltaY
                 .asInstanceOf[Double],
               p,
               id
             )
-          )
-          paint()
+            paintableElem = build(Some(paintableElem) -> event)
+            paint()
+            eventStore.add(event, id.canFuseEvents)
+          }
         }
       }
     }
 
-    def update = (k: Build[K]) =>
+    val update: Build[K] => Unit = (k: Build[K]) =>
       queueAnimationFrame { _ =>
         build = k
         paintableElem = k.build
+        // Replay accumulated events so externally-supplied state changes
+        // don't drop the user's current zoom/pan.
+        eventStore.get.foreach { (event: org.nspl.Event) =>
+          paintableElem = build(Some(paintableElem) -> event)
+        }
         paint()
       }
 
     canvas.onmousedown = onmousedown _
-    canvas.onmouseup = { _ =>
-      ctx.mousedown = false
-    }
+    canvas.onmouseup = onmouseup _
     canvas.onmousemove = onmove _
+    if (onShapeClick.isDefined) {
+      canvas.onclick = onclickHandler _
+    }
     canvas.addEventListener("wheel", onwheel _)
 
     queueAnimationFrame { _ =>
@@ -466,6 +692,7 @@ object canvasrenderer {
           drawAndFill(ctx, elem)
 
           elem.identifier match {
+            case EmptyIdentifier => ()
             case pa: PlotAreaIdentifier =>
               ctx.registerPlotArea(
                 elem.shape.transform((_, old) =>
@@ -473,7 +700,13 @@ object canvasrenderer {
                 ),
                 pa.copy(bounds = Some(elem.bounds))
               )
-            case _ =>
+            case other =>
+              val resolved = elem.shape.transform((_, old) =>
+                ctx.getAffineTransform.applyBefore(old)
+              )
+              ctx.registerOnClick(resolved, other)
+              ctx.registerOnHover(resolved, other)
+              ctx.registerOnSelection(resolved, other)
           }
         }
 
@@ -490,24 +723,74 @@ object canvasrenderer {
           ctx.withTransform(elem.tx) {
 
             ctx.withFill(elem.color) {
-              ctx.graphics.font = canvasFont(elem.font)
-              /* debug */
-              // ctx.setTransformInGraphics()
-              // ctx.graphics.strokeStyle = asCss(elem.color)
-              // ctx.graphics.strokeRect(
-              //   elem.layout.bounds.x,
-              //   elem.layout.bounds.y,
-              //   elem.layout.bounds.w,
-              //   elem.layout.bounds.h
-              // )
-              /* debug off */
+              // Bold and oblique map directly to the canvas font string.
+              // Sub/superscript are emulated as a smaller font with a
+              // vertical offset; underline is drawn as a line after the
+              // glyphs since the 2D canvas API has no underline switch.
+              val sub = elem.subScript && !elem.superScript
+              val sup = elem.superScript && !elem.subScript
+              val scaledFont =
+                if (sub || sup)
+                  new Font(
+                    elem.font.name,
+                    math.max(1, (elem.font.size * 0.7).toInt)
+                  )
+                else elem.font
+              ctx.graphics.font =
+                canvasFont(scaledFont, bold = elem.bold, italic = elem.oblique)
+
+              val ascent =
+                if (sub || sup) elem.font.size.toDouble * 0.7 else 0d
+              val yShift =
+                if (sup) -ascent * 0.4
+                else if (sub) ascent * 0.4
+                else 0d
+
               elem.layout.lines.foreach { case (line, lineTx) =>
                 ctx.withTransform(lineTx) {
                   ctx.setTransformInGraphics()
-                  ctx.graphics.fillText(line, 0, 0)
+                  ctx.graphics.fillText(line, 0, yShift)
+                  if (elem.underline) {
+                    val w = ctx.graphics.measureText(line).width
+                    // ~10% of font size below the baseline.
+                    val uy = yShift + scaledFont.size.toDouble * 0.1
+                    ctx.graphics.fillRect(
+                      0d,
+                      uy,
+                      w,
+                      math.max(1d, scaledFont.size.toDouble * 0.05)
+                    )
+                  }
                 }
               }
             }
+
+            // Register this text box as a hover/click/selection target if
+            // it carries a non-empty identifier. We register the bounding
+            // rectangle since `fillText` itself doesn't establish a path.
+            elem.identifier match {
+              case EmptyIdentifier => ()
+              case pa: PlotAreaIdentifier =>
+                ctx.registerPlotArea(
+                  Shape.rectangle(
+                    elem.bounds.x,
+                    elem.bounds.y,
+                    elem.bounds.w,
+                    elem.bounds.h
+                  ),
+                  pa.copy(bounds = Some(elem.bounds))
+                )
+              case other =>
+                val rect = Shape.rectangle(
+                  elem.bounds.x,
+                  elem.bounds.y,
+                  elem.bounds.w,
+                  elem.bounds.h
+                )
+                ctx.registerOnClick(rect, other)
+                ctx.registerOnHover(rect, other)
+                ctx.registerOnSelection(rect, other)
+            }
           }
         }
       }
diff --git a/canvas/src/test/scala/test.scala b/canvas/src/test/scala/test.scala
index 1f2f358c..235c5fc7 100644
--- a/canvas/src/test/scala/test.scala
+++ b/canvas/src/test/scala/test.scala
@@ -1,151 +1,94 @@
 import org.nspl._
-import org.nspl.data._
 import canvasrenderer._
-import org.scalajs.dom._
+import org.scalajs.dom.document
 
-import scala.scalajs.js
-import js.annotation._
+import scala.scalajs.js.annotation._
 
+/** Interactive canvas demo. The DOM scaffolding (title, instructions, slots,
+  * log) lives in `canvas/index.html`; this object only attaches the rendered
+  * canvases to the placeholder elements and routes interaction callbacks to
+  * the `#event-log` `
`. Build with `sbt canvas/Test/fastLinkJS` and
+  * open `canvas/index.html`.
+  */
 @JSExportTopLevel("nsplcanvastest")
 object nsplcanvastest {
-  @JSExport
-  def bind(n: Node): Unit = {
-    println("Hi")
-
-    def random = 1 to 1000 map (_ => scala.util.Random.nextDouble())
-    def random2 = 1 to 1000 map (_ => scala.util.Random.nextGaussian())
-
-    val x = random
-    val y = random
-    val z = x zip y map (x => x._1 * x._2)
-    val z2 = random2
-    val z3 = random2
 
-    val p1 = xyplot(
+  private def buildScatter() = {
+    val rng = new scala.util.Random(42)
+    val data = (1 to 200).map { i =>
+      val cluster = i / 50
       (
-        indexed(x),
-        List(
-          point(shapes = Vector(shapeList(1)))
-          // line()
-        ),
-        InLegend("dsf")
+        rng.nextGaussian() + cluster * 3d,
+        rng.nextGaussian() + cluster * 2d,
+        cluster.toDouble
       )
-    )(
-      par.
-        ylab ("x")
-        .xlab ("index")
-        .main(
-          "main\nsdfsd\nasdfsdfd fasd fds fds fds fds fda fdsa fd d ds fds ds df asdf asdf sad sd fsad fsda sda fdsaf ")
-      )
-    
-
-    val p2 = xyplot(
-      density(x) -> line()
-    )(par.xlab("x").ylab("dens"))
-    val p3 = xyplot(
-      z2 -> z3 -> point(size = 1d, color = Color(200, 200, 200, 255)),
-      density2d(z2 zip z3, n = 100, levels = 10)
-    )()
-    val p4 = xyplot(
-      (x zip y zip z map (x => (x._1._1, x._1._2, x._2, x._2 * 10))) -> point(
-        color = HeatMapColors(0.0, 1.0),
-        shapeCol = 3,
-        sizeCol = 5
+    }
+    xyplot(
+      data -> point(
+        color = DiscreteColors(8),
+        size = 5d,
+        noIdentifier = false
       )
-    )()
-
-    val p5 = binnedboxplot(x, y)(par.xlab("PC2").ylab ("PC3"))
-
-    val p6 = rasterplot(
-      rasterFromStream(z3.iterator, 30, 30, MinMaxImpl(0.0, 1.0))
-    )(par.xLabFontSize(0.5 fts).yLabFontSize(0.5 fts))
+    )(par.xlab("x").ylab("y"))
+  }
 
-    val text: Elems7[
-      ShapeElem,
-      TextBox,
-      TextBox,
-      TextBox,
-      TextBox,
-      TextBox,
-      TextBox
-    ] = fitToWidth(
-      group(
-        ShapeElem(Shape.circle(1)),
-        TextBox("abc def ghijklmn opqrstvuwxyz"),
-        TextBox("abc def ghijklmn opqrstvuwxyz", width = Some(30d))
-          .translate(10, 30),
-        TextBox("abc def ghijklmn opqrstvuwxyz", width = Some(30d))
-          .translate(10, 30)
-          .rotate(math.Pi / 2, 0d, 0d),
-        TextBox("abc def ghijklmn", fontSize = 0.1 fts).rotate(1d),
-        TextBox("opqrstvuwxyz", fontSize = 0.1 fts).translate(10, 30),
-        TextBox("abc def ghijklmn opqrstvuwxyz", fontSize = 1 fts)
-          .translate(100, 30),
-        FreeLayout
-      ),
-      200
-    )
+  private def buildLine() = xyplot(
+    (1 to 50).map(i => (i.toDouble, math.sin(i * 0.2))) -> line()
+  )(par.xlab("i").ylab("sin"))
+
+  private def fmtId(id: Identifier): String = id match {
+    case DataRowIdx(ext, ds, row) =>
+      s"DataRow(externalDS=$ext, ds=$ds, row=$row)"
+    case TextBoxIdentifier(label, idx) =>
+      s"TextBox(label=$label, index=$idx)"
+    case PlotAreaIdentifier(_, _, _) => "PlotArea"
+    case EmptyIdentifier              => "Empty"
+    case other                        => other.toString
+  }
 
-    val cubeVertex: DataSource = List(
-      (0d, 0d, 0d),
-      (100d, 0d, 0d),
-      (100d, 100d, 0d),
-      (100d, 100d, 0d),
-      (100d, 0d, 100d)
+  @JSExport
+  def bind(): Unit = {
+    val scatterSlot = document.getElementById("scatter-plot")
+    val lineSlot = document.getElementById("line-plot")
+    val log = document.getElementById("event-log")
+
+    if (scatterSlot == null || lineSlot == null || log == null) {
+      // index.html shape changed and the slots aren't where we expect.
+      // Surface the mismatch loudly rather than silently doing nothing.
+      throw new RuntimeException(
+        "nsplcanvastest: expected DOM elements #scatter-plot, #line-plot, #event-log"
+      )
+    }
+
+    def emit(line: String): Unit = {
+      val p = document.createElement("div")
+      p.textContent = line
+      log.insertBefore(p, log.firstChild)
+      while (log.childNodes.length > 200) log.removeChild(log.lastChild)
+    }
+
+    val (scatterCanvas, _) = render(
+      buildScatter(),
+      width = 600,
+      height = 400,
+      onShapeClick = Some { (id, _) => emit(s"click  ${fmtId(id)}") },
+      onHover = Some { (id, _) => emit(s"hover  ${fmtId(id)}") },
+      onUnhover = Some { (id, _) => emit(s"unhover ${fmtId(id)}") },
+      onSelection = Some { ids =>
+        val sample = ids.take(8).map(fmtId).mkString(", ")
+        val tail = if (ids.size > 8) " …" else ""
+        emit(s"select ${ids.size} shapes  → $sample$tail")
+      }
     )
-
-    val cube: DataSource =
-      List(
-        (0d, 0d, 0d),
-        (100d, 0d, 0d),
-        (100d, 0d, 0d),
-        (100d, 100d, 0d),
-        (100d, 100d, 0d),
-        (0d, 100d, 0d),
-        (0d, 100d, 0d),
-        (0d, 0d, 0d),
-        (0d, 0d, 100d),
-        (100d, 0d, 100d),
-        (100d, 0d, 100d),
-        (100d, 100d, 100d),
-        (100d, 100d, 100d),
-        (0d, 100d, 100d),
-        (0d, 100d, 100d),
-        (0d, 0d, 100d),
-        (0d, 0d, 0d),
-        (0d, 0d, 100d),
-        (100d, 0d, 0d),
-        (100d, 0d, 100d),
-        (100d, 100d, 0d),
-        (100d, 100d, 100d),
-        (0d, 100d, 0d),
-        (0d, 100d, 100d)
-      ).grouped(2)
-        .toList
-        .map(v => (v(0)._1, v(0)._2, v(0)._3, v(1)._1, v(1)._2, v(1)._3))
-
-    val xyzp: Build[Elems2[XYZPlotArea, Legend]] = xyzplot(
-      (cube, List(lineSegment3D()), NotInLegend),
-      (cubeVertex, List(point3D()), NotInLegend)
-    )()
-
-    val gallery = group(
-      xyzp,
-      p1,
-      p2,
-      p2,
-      p3,
-      p4,
-      p5,
-      p6,
-      text,
-      VerticalStack(Align.Anchor)
+    val (lineCanvas, _) = render(
+      buildLine(),
+      width = 600,
+      height = 200,
+      click = id => emit(s"plotMouseDown ${fmtId(id)}")
     )
 
-    val (canv, _) = render(gallery, 800, 800, println)
-
-    n.appendChild(canv)
-
-    println("Bye")
+    scatterSlot.appendChild(scatterCanvas)
+    lineSlot.appendChild(lineCanvas)
+    emit("ready")
   }
 }
diff --git a/core/src/main/scala/org/nspl/axis.scala b/core/src/main/scala/org/nspl/axis.scala
index 9187fc2e..7b0743cc 100644
--- a/core/src/main/scala/org/nspl/axis.scala
+++ b/core/src/main/scala/org/nspl/axis.scala
@@ -2,11 +2,17 @@ package org.nspl
 
 trait Axis {
   def worldToView(v: Double): Double
+
+  /** Inverse of [[worldToView]]. View coordinates start at 0 and run to
+    * [[width]]; this maps them back to data-space.
+    */
+  def viewToWorld(v: Double): Double
   def min: Double
   def max: Double
   def width = math.abs(worldToView(max) - worldToView(min))
   def horizontal: Boolean
   def log: Boolean
+  def isLog2: Boolean = false
 }
 
 sealed trait AxisFactory {
@@ -19,6 +25,7 @@ object LinearAxisFactory extends AxisFactory {
       new Axis {
         val tmp = width1 / (max1 - min1)
         def worldToView(v: Double) = (v - min1) * tmp
+        def viewToWorld(x: Double) = x / tmp + min1
         val min = if (min1 == max1) max1 - 1d else min1
         val max = if (min1 == max1) max1 + 1d else max1
         val horizontal = true
@@ -31,6 +38,7 @@ object LinearAxisFactory extends AxisFactory {
 
         def worldToView(v: Double) =
           width1 - (v - min1) * tmp
+        def viewToWorld(x: Double) = (width1 - x) / tmp + min1
         val min = if (min1 == max1) max1 - 1d else min1
         val max = if (min1 == max1) max1 + 1d else max1
         val horizontal = false
@@ -42,12 +50,15 @@ object Log10AxisFactory extends AxisFactory {
   def make(min1: Double, max1: Double, width1: Double, horizontal: Boolean) = {
     val lMin1 = math.log10(min1)
     val lMax1 = math.log10(max1)
+    val lRange = lMax1 - lMin1
     if (horizontal)
       new Axis {
         def worldToView(v: Double) = {
           if (v <= 0d) throw new RuntimeException("<0")
-          (math.log10(v) - lMin1) / (lMax1 - lMin1) * width1
+          (math.log10(v) - lMin1) / lRange * width1
         }
+        def viewToWorld(x: Double) =
+          math.pow(10d, lMin1 + (x / width1) * lRange)
         val min = if (min1 == max1) max1 - 1d else min1
         val max = if (min1 == max1) max1 + 1d else max1
         val horizontal = true
@@ -57,8 +68,10 @@ object Log10AxisFactory extends AxisFactory {
       new Axis {
         def worldToView(v: Double) = {
           if (v <= 0d) throw new RuntimeException("<0")
-          width1 - (math.log10(v) - lMin1) / (lMax1 - lMin1) * width1
+          width1 - (math.log10(v) - lMin1) / lRange * width1
         }
+        def viewToWorld(x: Double) =
+          math.pow(10d, lMin1 + ((width1 - x) / width1) * lRange)
         val min = if (min1 == max1) max1 - 1d else min1
         val max = if (min1 == max1) max1 + 1d else max1
         val horizontal = false
@@ -67,6 +80,45 @@ object Log10AxisFactory extends AxisFactory {
   }
 }
 
+object Log2AxisFactory extends AxisFactory {
+  private val ln2 = math.log(2d)
+  private def log2(x: Double) = math.log(x) / ln2
+
+  def make(min1: Double, max1: Double, width1: Double, horizontal: Boolean) = {
+    val lMin1 = log2(min1)
+    val lMax1 = log2(max1)
+    val lRange = lMax1 - lMin1
+    if (horizontal)
+      new Axis {
+        def worldToView(v: Double) = {
+          if (v <= 0d) throw new RuntimeException("<0")
+          (log2(v) - lMin1) / lRange * width1
+        }
+        def viewToWorld(x: Double) =
+          math.pow(2d, lMin1 + (x / width1) * lRange)
+        val min = if (min1 == max1) max1 - 1d else min1
+        val max = if (min1 == max1) max1 + 1d else max1
+        val horizontal = true
+        val log = true
+        override val isLog2 = true
+      }
+    else
+      new Axis {
+        def worldToView(v: Double) = {
+          if (v <= 0d) throw new RuntimeException("<0")
+          width1 - (log2(v) - lMin1) / lRange * width1
+        }
+        def viewToWorld(x: Double) =
+          math.pow(2d, lMin1 + ((width1 - x) / width1) * lRange)
+        val min = if (min1 == max1) max1 - 1d else min1
+        val max = if (min1 == max1) max1 + 1d else max1
+        val horizontal = false
+        val log = true
+        override val isLog2 = true
+      }
+  }
+}
+
 object AxisSettings {
 
   def simple(axisFactory: AxisFactory)(implicit
@@ -171,7 +223,30 @@ class AxisSettings(
       customTicks
         .filter(i => i._1 >= axis.min && i._1 <= axis.max)
 
-    val (majorTicks1, minorTicks1) = if (axis.log) {
+    val (majorTicks1, minorTicks1) = if (axis.isLog2) {
+      val ln2 = math.log(2d)
+      def log2(x: Double) = math.log(x) / ln2
+      val lmaj1 =
+        (log2(axis.min).ceil.toInt until log2(axis.max).toInt)
+          .map(_.toDouble)
+          .filter { i =>
+            val e = math.pow(2d, i)
+            e >= axis.min - 1e-3 && e <= axis.max + 1e-3
+          }
+      val majorTicksExp =
+        axis.min +: (lmaj1.map(i => math.pow(2d, i)) :+ axis.max)
+      val minorTicksExp = majorTicksExp
+        .sliding(2)
+        .flatMap { group =>
+          val m1 = group(0)
+          val m2 = group(1)
+          val space = (m2 - m1) / numMinorTicksFactor
+          (0 to numMinorTicksFactor.toInt).map(i => m1 + i * space)
+        }
+        .filterNot(majorTicksExp.contains)
+        .toList
+      (majorTicksExp, minorTicksExp)
+    } else if (axis.log) {
       val lmaj1 =
         (math
           .log10(axis.min)
diff --git a/core/src/main/scala/org/nspl/color.scala b/core/src/main/scala/org/nspl/color.scala
index defdafdb..9f837de2 100644
--- a/core/src/main/scala/org/nspl/color.scala
+++ b/core/src/main/scala/org/nspl/color.scala
@@ -66,6 +66,23 @@ object TableColormap {
   )
 }
 
+/** Discrete colormap keyed by exact `Double` values. Like [[TableColormap]]
+  * but returns a configurable `default` for missing keys (rather than
+  * `Color.gray5`), and maps `NaN` to `Color.transparent`.
+  */
+case class ManualColor(map: Map[Double, Color], default: Color = Color.gray5)
+    extends Colormap {
+  def apply(v: Double): Color =
+    if (v.isNaN) Color.transparent else map.getOrElse(v, default)
+  def withRange(min: Double, max: Double) = this
+}
+
+object ManualColor {
+  def apply(colors: Color*): ManualColor = ManualColor(
+    colors.zipWithIndex.map(x => x._2.toDouble -> x._1).toMap
+  )
+}
+
 case class HeatMapColors(
     min: Double = 0.0,
     max: Double = 1.0,
diff --git a/core/src/main/scala/org/nspl/core.scala b/core/src/main/scala/org/nspl/core.scala
index 0e24c169..c74ebbc2 100644
--- a/core/src/main/scala/org/nspl/core.scala
+++ b/core/src/main/scala/org/nspl/core.scala
@@ -23,6 +23,18 @@ case class Bounds(
 
 }
 
+object Bounds {
+
+  /** Smallest axis-aligned bounding rectangle containing both points. */
+  def fromPoints(p1: Point, p2: Point): Bounds = {
+    val xMin = math.min(p1.x, p2.x)
+    val yMin = math.min(p1.y, p2.y)
+    val xMax = math.max(p1.x, p2.x)
+    val yMax = math.max(p1.y, p2.y)
+    Bounds(xMin, yMin, xMax - xMin, yMax - yMin)
+  }
+}
+
 /** Line cap style */
 sealed trait Cap
 object Cap {
@@ -139,7 +151,40 @@ class PlotId
 trait Identifier
 case object EmptyIdentifier extends Identifier
 
-/** Final rendered bounds (if available) and identifier of a plot area
+/** Final rendered bounds (if available) and identifier of a plot area.
+  *
+  * @param canFuseEvents
+  *   if true, the canvas event store may collapse consecutive Drag/Selection
+  *   events on this plot area into a single event when replaying.
+  */
+case class PlotAreaIdentifier(
+    id: PlotId,
+    bounds: Option[Bounds],
+    canFuseEvents: Boolean = true
+) extends Identifier
+
+/** Identifies a single row in a [[data.DataSource]] within a plot.
+  *
+  * @param externalDataSourceIdx
+  *   index of the dataset among all datasets in the plot
+  * @param dataSourceIdx
+  *   index of the dataset among the datasets sharing the same renderer
+  * @param rowIdx
+  *   row index within the dataset
+  */
+case class DataRowIdx(
+    externalDataSourceIdx: Int,
+    dataSourceIdx: Int,
+    rowIdx: Int
+) extends Identifier
+
+/** Identifies a [[TextBox]] that should be interactive (clickable, hoverable,
+  * selectable). Use this on labels — legend items, axis tick labels, data
+  * labels — when you want click/hover callbacks to fire on the text itself
+  * rather than the data underneath it.
+  *
+  * The `label` is arbitrary and chosen by whoever constructed the TextBox;
+  * `index` is for the common case of "the Nth legend item" or "the Nth
+  * tick".
   */
-case class PlotAreaIdentifier(id: PlotId, bounds: Option[Bounds])
-    extends Identifier
+case class TextBoxIdentifier(label: String, index: Int = 0) extends Identifier
diff --git a/core/src/main/scala/org/nspl/data/SparseDataMatrix.scala b/core/src/main/scala/org/nspl/data/SparseDataMatrix.scala
new file mode 100644
index 00000000..5294cb4c
--- /dev/null
+++ b/core/src/main/scala/org/nspl/data/SparseDataMatrix.scala
@@ -0,0 +1,107 @@
+package org.nspl.data
+
+object SparseDataMatrix {
+
+  /** Sorts `elems` into row-major order and constructs a [[SparseDataMatrix]].
+    * The first `Int` is the row index, the second is the column index.
+    */
+  def fromUnsorted(
+      elems: Vector[(Int, Int, Double)],
+      numCols: Int,
+      numRows: Int,
+      missingValue: Double
+  ): SparseDataMatrix =
+    SparseDataMatrix(
+      elems.sortBy(_._2).sortBy(_._1),
+      numCols,
+      numRows,
+      missingValue
+    )
+}
+
+/** A `DataSource` view over a sparse matrix. Missing cells return
+  * `missingValue`. Yields one [[Row]] per (row, col) pair in row-major order;
+  * each row has three columns: (colIndex, rowIndex, value).
+  *
+  * `elemsSorted` MUST be sorted in row-major order
+  * (`(rowIndex * numCols + colIndex)` ascending). Use
+  * [[SparseDataMatrix.fromUnsorted]] if not already sorted.
+  */
+case class SparseDataMatrix(
+    elemsSorted: Vector[(Int, Int, Double)],
+    numCols: Int,
+    numRows: Int,
+    missingValue: Double
+) extends DataSource {
+
+  val dimension = 3
+  private val n = numCols.toLong * numRows.toLong
+
+  def iterator: Iterator[Row] = new Iterator[Row] {
+    private var k = 0L
+    private var ptr = 0
+    private val sortedSize = elemsSorted.size
+
+    def hasNext: Boolean = k < n
+
+    def next(): Row = {
+      if (!hasNext) throw new NoSuchElementException("SparseDataMatrix.next")
+      val iRow = (k / numCols).toInt
+      val jCol = (k % numCols).toInt
+
+      while (ptr < sortedSize && {
+               val t = elemsSorted(ptr)
+               t._1.toLong * numCols + t._2 < k
+             }) ptr += 1
+
+      val value =
+        if (ptr < sortedSize) {
+          val t = elemsSorted(ptr)
+          if (t._1.toLong * numCols + t._2 == k) t._3 else missingValue
+        } else missingValue
+
+      k += 1L
+
+      new Row {
+        def apply(l: Int): Double =
+          if (l == 0) jCol.toDouble
+          else if (l == 1) iRow.toDouble
+          else value
+        def allColumns: Seq[Double] =
+          Vector(jCol.toDouble, iRow.toDouble, value)
+        def dimension: Int = 3
+        def label: String = ""
+        override def toString: String = value.toString
+      }
+    }
+  }
+
+  def columnMinMax(i: Int): Option[MinMax] = Some {
+    if (i == 0)
+      MinMaxImpl(0.0, (numCols - 1).toDouble)
+    else if (i == 1)
+      MinMaxImpl(0.0, (numRows - 1).toDouble)
+    else {
+      val (minV, maxV) =
+        if (elemsSorted.isEmpty) (missingValue, missingValue)
+        else {
+          val values = elemsSorted.iterator.map(_._3)
+          var lo = Double.PositiveInfinity
+          var hi = Double.NegativeInfinity
+          while (values.hasNext) {
+            val v = values.next()
+            if (v < lo) lo = v
+            if (v > hi) hi = v
+          }
+          (lo, hi)
+        }
+      // include the missing-value sentinel only if there is at least one
+      // missing cell in the matrix; otherwise it would skew the range.
+      val hasMissing = elemsSorted.size.toLong < n
+      MinMaxImpl(
+        if (hasMissing) math.min(minV, missingValue) else minV,
+        if (hasMissing) math.max(maxV, missingValue) else maxV
+      )
+    }
+  }
+}
diff --git a/core/src/main/scala/org/nspl/data/histogram.scala b/core/src/main/scala/org/nspl/data/histogram.scala
index 347e48f3..efcb999f 100644
--- a/core/src/main/scala/org/nspl/data/histogram.scala
+++ b/core/src/main/scala/org/nspl/data/histogram.scala
@@ -19,6 +19,31 @@ case class HistogramData(
       lastBinUpperBound
     )
 
+  /** Normalize the histogram so the total area under the bars sums to 1.
+    *
+    * Treats each bin's height as a probability density on `(xstart, xend)`.
+    * Assumes `ystart` is `0` for every bin (i.e. the histogram hasn't been
+    * stacked).
+    */
+  def density: HistogramData = {
+    val totalArea = bins.iterator.map {
+      case ((x1, x2, _), y2) => (x2 - x1) * y2
+    }.sum
+    if (totalArea <= 0d) this
+    else
+      HistogramData(
+        bins.map { case ((x1, x2, _), y2) =>
+          val area = (x2 - x1) * y2
+          ((x1, x2, 0d), area / totalArea / (x2 - x1))
+        },
+        minX,
+        maxX,
+        maxY / n,
+        1,
+        lastBinUpperBound
+      )
+  }
+
   def filterBinsMinValue(m: Double) = this.copy(bins = bins.filter(_._2 >= m))
 
   def /(that: HistogramData): HistogramData =
@@ -52,10 +77,17 @@ case class HistogramData(
       )
     }
 
+  /** Convert the bins to scatter-plot rows `(midX, top, 0, width, ystart)`.
+    * Zero-height bins are dropped so that downstream renderers do not emit
+    * empty bars or label clutter.
+    */
   def toScatter =
-    bins.toSeq.map { case ((xstart, xend, ystart), height) =>
-      ((xstart + xend) * 0.5, (ystart + height), 0d, xend - xstart, ystart)
-    } sortBy (_._1)
+    bins.toSeq
+      .map { case ((xstart, xend, ystart), height) =>
+        ((xstart + xend) * 0.5, (ystart + height), 0d, xend - xstart, ystart)
+      }
+      .filter(_._2 > 0)
+      .sortBy(_._1)
 }
 object HistogramData {
 
diff --git a/core/src/main/scala/org/nspl/datarenderers.scala b/core/src/main/scala/org/nspl/datarenderers.scala
index 50458bef..9401fb08 100644
--- a/core/src/main/scala/org/nspl/datarenderers.scala
+++ b/core/src/main/scala/org/nspl/datarenderers.scala
@@ -19,13 +19,38 @@ import data._
   * short for the given data renderer then an error is thrown.
   */
 trait DataRenderer {
+
+  /** Render one data row into `ctx`.
+    *
+    * @param descriptor
+    *   identifier to attach to any rendered shapes for interactivity (e.g. a
+    *   [[DataRowIdx]] supplied by the surrounding [[DataElem]]). Renderers
+    *   that opt-in to per-row identification (most do via a `noIdentifier`
+    *   flag defaulting to true) attach this to their emitted shapes; others
+    *   simply ignore it.
+    */
   def render[R <: RenderingContext[R]](
       data: Row,
       xAxis: Axis,
       yAxis: Axis,
       ctx: R,
-      tx: AffineTransform
+      tx: AffineTransform,
+      descriptor: Identifier
   )(implicit re: Renderer[ShapeElem, R], rt: Renderer[TextBox, R]): Unit
+
+  /** Compatibility overload — call with no descriptor. Forwards to the
+    * descriptor-aware overload with [[EmptyIdentifier]]. Used by older
+    * callers that haven't been threaded through the interactivity surface.
+    */
+  final def render[R <: RenderingContext[R]](
+      data: Row,
+      xAxis: Axis,
+      yAxis: Axis,
+      ctx: R,
+      tx: AffineTransform
+  )(implicit re: Renderer[ShapeElem, R], rt: Renderer[TextBox, R]): Unit =
+    render(data, xAxis, yAxis, ctx, tx, EmptyIdentifier)
+
   def asLegend: Option[LegendElem]
   @scala.annotation.nowarn
   def clear[R <: RenderingContext[R]](ctx: R)(implicit
@@ -47,8 +72,13 @@ private[nspl] trait Renderers {
       shapeCol: Int = 4,
       errorTopCol: Int = 5,
       errorBottomCol: Int = 6,
+      errorLeftCol: Int = 9,
+      errorRightCol: Int = 10,
+      strokeColorCol: Int = 8,
       size: Double = 3d,
       color: Colormap = DiscreteColors(14),
+      strokeColor: Option[Colormap] = None,
+      strokeWidth: Option[Double] = None,
       shapes: Vector[Shape] = shapeList,
       pointSizeIsInDataSpaceUnits: Boolean = false,
       keepPointShapeAspectRatio: Boolean = false,
@@ -62,7 +92,8 @@ private[nspl] trait Renderers {
       translate: (Double, Double) = (0d, 0d),
       xNoise: Double = 0d,
       yNoise: Double = 0d,
-      label: Any => String = _.toString
+      label: Any => String = _.toString,
+      noIdentifier: Boolean = true
   ) = new DataRenderer {
 
     def asLegend = Some(PointLegend(shapes.head, color(0)))
@@ -113,7 +144,8 @@ private[nspl] trait Renderers {
         xAxis: Axis,
         yAxis: Axis,
         ctx: R,
-        tx: AffineTransform
+        tx: AffineTransform,
+        descriptor: Identifier
     )(implicit re: Renderer[ShapeElem, R], rt: Renderer[TextBox, R]): Unit = {
 
       if (data.dimension > xCol && data.dimension > yCol) {
@@ -131,7 +163,13 @@ private[nspl] trait Renderers {
 
           if (!skip) {
             val color1 = color(dataColorValue)
-            if (color1.a > 0) {
+            val strokeColor1 = strokeColor.map { cm =>
+              val v =
+                if (data.dimension > strokeColorCol) data(strokeColorCol)
+                else dataColorValue
+              cm(v)
+            }
+            if (color1.a > 0 || strokeColor1.exists(_.a > 0)) {
               val shape =
                 if (data.dimension > shapeCol)
                   shapes(data(shapeCol).toInt % shapes.size)
@@ -168,11 +206,17 @@ private[nspl] trait Renderers {
               val vX = xAxis.worldToView(wX + translate._1 + noiseValueX)
               val vY = yAxis.worldToView(wY + translate._2 + noiseValueY)
 
+              val pointStroke =
+                strokeWidth.map(w => Stroke(w))
+              val pointStrokeColor = strokeColor1.getOrElse(Color.black)
+
               val shape1PreTransform: ShapeElem =
                 if (valueText || labelText)
                   ShapeElem(
                     shape,
                     fill = color1,
+                    strokeColor = pointStrokeColor,
+                    stroke = pointStroke,
                     tx = AffineTransform
                       .scaleThenTranslate(vX, vY, factorX, factorY)
                   )
@@ -183,6 +227,8 @@ private[nspl] trait Renderers {
                   ShapeElem(
                     shape,
                     fill = color1,
+                    strokeColor = pointStrokeColor,
+                    stroke = pointStroke,
                     tx = tx.scaleThenTranslate(vX, vY, factorX, factorY)
                   )
               if (data.dimension > errorTopCol) {
@@ -194,7 +240,7 @@ private[nspl] trait Renderers {
                   ),
                   stroke = Some(errorBarStroke.value),
                   tx = tx,
-                  fill = errorBarColor
+                  strokeColor = errorBarColor
                 )
                 re.render(ctx, shape1)
               }
@@ -207,11 +253,40 @@ private[nspl] trait Renderers {
                   ),
                   stroke = Some(errorBarStroke.value),
                   tx = tx,
-                  fill = errorBarColor
+                  strokeColor = errorBarColor
+                )
+                re.render(ctx, shape1)
+              }
+              if (data.dimension > errorLeftCol) {
+                val errorLeft = data(errorLeftCol)
+                val shape1: ShapeElem = ShapeElem(
+                  Shape.line(
+                    Point(vX, vY),
+                    Point(xAxis.worldToView(errorLeft), vY)
+                  ),
+                  stroke = Some(errorBarStroke.value),
+                  tx = tx,
+                  strokeColor = errorBarColor
+                )
+                re.render(ctx, shape1)
+              }
+              if (data.dimension > errorRightCol) {
+                val errorRight = data(errorRightCol)
+                val shape1: ShapeElem = ShapeElem(
+                  Shape.line(
+                    Point(vX, vY),
+                    Point(xAxis.worldToView(errorRight), vY)
+                  ),
+                  stroke = Some(errorBarStroke.value),
+                  tx = tx,
+                  strokeColor = errorBarColor
                 )
                 re.render(ctx, shape1)
               }
-              re.render(ctx, shape1)
+              val shape1WithId =
+                if (noIdentifier) shape1
+                else shape1.withIdentifier(descriptor)
+              re.render(ctx, shape1WithId)
 
               if (valueText && data.dimension > colorCol) {
                 val tbPreTransform = TextBox(
@@ -300,7 +375,8 @@ private[nspl] trait Renderers {
         xAxis: Axis,
         yAxis: Axis,
         ctx: R,
-        tx: AffineTransform
+        tx: AffineTransform,
+        descriptor: Identifier
     )(implicit re: Renderer[ShapeElem, R], rt: Renderer[TextBox, R]): Unit = {
 
       val wX = data(xCol)
@@ -335,6 +411,10 @@ private[nspl] trait Renderers {
           currentPoint = Some(p)
 
         }
+      } else {
+        // Reset so the next in-range point starts a new segment instead of
+        // silently bridging the out-of-range gap.
+        currentPoint = None
       }
     }
   }
@@ -363,9 +443,19 @@ private[nspl] trait Renderers {
 
     def xMinMax(ds: DataSource) = ds.columnMinMax(xCol)
     def yMinMax(ds: DataSource) = {
-      val max = ds.columnMinMax(yCol).map(_.max)
-      val min = yCol2.flatMap(y => ds.columnMinMax(y).map(_.min)).getOrElse(0d)
-      max.map(max => MinMaxImpl(min, max))
+      val mm1 = ds.columnMinMax(yCol)
+      val mm2 = yCol2.flatMap(ds.columnMinMax)
+      // Area is bounded by both curves: take the union so neither one
+      // clips out of view.
+      mm1.map { a =>
+        mm2 match {
+          case Some(b) =>
+            MinMaxImpl(math.min(a.min, b.min), math.max(a.max, b.max))
+          case None =>
+            // No second curve: area runs from 0 to yCol's max (legacy).
+            MinMaxImpl(math.min(0d, a.min), a.max)
+        }
+      }
     }
 
     def render[R <: RenderingContext[R]](
@@ -373,7 +463,8 @@ private[nspl] trait Renderers {
         xAxis: Axis,
         yAxis: Axis,
         ctx: R,
-        tx: AffineTransform
+        tx: AffineTransform,
+        descriptor: Identifier
     )(implicit re: Renderer[ShapeElem, R], rt: Renderer[TextBox, R]): Unit = {
 
       val wX = data(xCol)
@@ -462,7 +553,8 @@ private[nspl] trait Renderers {
         xAxis: Axis,
         yAxis: Axis,
         ctx: R,
-        tx: AffineTransform
+        tx: AffineTransform,
+        descriptor: Identifier
     )(implicit re: Renderer[ShapeElem, R], rt: Renderer[TextBox, R]): Unit = {
 
       val r = renderer()
@@ -470,7 +562,7 @@ private[nspl] trait Renderers {
       0 to 1000 foreach { i =>
         val x = xAxis.min + i * (xAxis.max - xAxis.min) / 1000.0
         val y = evaluatePolynomial(data.allColumns.toArray, x)
-        r.render(VectorRow(Vector(x, y), ""), xAxis, yAxis, ctx, tx)
+        r.render(VectorRow(Vector(x, y), ""), xAxis, yAxis, ctx, tx, descriptor)
       }
 
     }
@@ -521,7 +613,8 @@ private[nspl] trait Renderers {
         xAxis: Axis,
         yAxis: Axis,
         ctx: R,
-        tx: AffineTransform
+        tx: AffineTransform,
+        descriptor: Identifier
     )(implicit re: Renderer[ShapeElem, R], rt: Renderer[TextBox, R]): Unit = {
 
       val wX = data(xCol)
@@ -550,16 +643,15 @@ private[nspl] trait Renderers {
               else 0d
             )
 
-          val vX = xAxis.worldToView(wX)
-          val vXMin = xAxis.worldToView(xAxis.min)
-          val vXMax = xAxis.worldToView(xAxis.max)
-          val vWidth1 =
-            math.abs(xAxis.worldToView(0.0) - xAxis.worldToView(width1))
-
-          val outOfBoundsLeft = math.max(0d, vXMin - (vX - vWidth1 * 0.5))
-          val outOfBoundsRight = math.max(0d, vX + vWidth1 * 0.5 - vXMax)
-
-          val vWidth = vWidth1 - outOfBoundsLeft - outOfBoundsRight
+          // Compute the bar's left/right view coords directly — this works
+          // for log axes too (where `worldToView(0)` would throw because
+          // 0 is not in the log domain).
+          val leftW = math.max(xAxis.min, wX - width1 * 0.5)
+          val rightW = math.min(xAxis.max, wX + width1 * 0.5)
+          val vL = xAxis.worldToView(leftW)
+          val vR = xAxis.worldToView(rightW)
+          val vWidth = math.abs(vR - vL)
+          val vLeftEdge = math.min(vL, vR)
 
           val vY2 = yAxis.worldToView(wYBottom)
           val vY = yAxis.worldToView(wY)
@@ -568,19 +660,9 @@ private[nspl] trait Renderers {
 
           val rectangle =
             if (vY2 > vY)
-              Shape.rectangle(
-                vX - vWidth1 * 0.5 + outOfBoundsLeft,
-                vY,
-                vWidth,
-                vHeight
-              )
+              Shape.rectangle(vLeftEdge, vY, vWidth, vHeight)
             else
-              Shape.rectangle(
-                vX - vWidth1 * 0.5 + outOfBoundsLeft,
-                vY2,
-                vWidth,
-                vHeight
-              )
+              Shape.rectangle(vLeftEdge, vY2, vWidth, vHeight)
 
           val shape1 = ShapeElem(
             rectangle,
@@ -607,17 +689,15 @@ private[nspl] trait Renderers {
               else 0d
             )
 
-          val vY = yAxis.worldToView(wY)
-          val vYMin = yAxis.worldToView(yAxis.min)
-          val vYMax = yAxis.worldToView(yAxis.max)
-
-          val vWidth1 =
-            math.abs(yAxis.worldToView(0.0) - yAxis.worldToView(width))
-
-          val outOfBoundsTop = math.max(0d, vYMax - (vY - vWidth1 * 0.5))
-          val outOfBoundsBottom = math.max(0d, vY + vWidth1 * 0.5 - vYMin)
-
-          val vWidth = vWidth1 - outOfBoundsTop - outOfBoundsBottom
+          // Bug fix: previously used `width` instead of `width1`, ignoring
+          // the per-row widthCol. Also log-axis-safe by going through bar
+          // edges directly rather than via `worldToView(0)`.
+          val topW = math.max(yAxis.min, wY - width1 * 0.5)
+          val botW = math.min(yAxis.max, wY + width1 * 0.5)
+          val vT = yAxis.worldToView(topW)
+          val vB = yAxis.worldToView(botW)
+          val vWidth = math.abs(vB - vT)
+          val vTopEdge = math.min(vT, vB)
 
           val vX = xAxis.worldToView(wX)
           val vX2 = xAxis.worldToView(wXBottom)
@@ -625,19 +705,9 @@ private[nspl] trait Renderers {
 
           val rectangle =
             if (wX > 0)
-              Shape.rectangle(
-                vX2,
-                vY - vWidth1 * 0.5 + outOfBoundsTop,
-                vHeight,
-                vWidth
-              )
+              Shape.rectangle(vX2, vTopEdge, vHeight, vWidth)
             else
-              Shape.rectangle(
-                vX,
-                vY - vWidth1 * 0.5 + outOfBoundsTop,
-                vHeight,
-                vWidth
-              )
+              Shape.rectangle(vX, vTopEdge, vHeight, vWidth)
 
           val shape1 = ShapeElem(
             rectangle,
@@ -707,7 +777,8 @@ private[nspl] trait Renderers {
         xAxis: Axis,
         yAxis: Axis,
         ctx: R,
-        tx: AffineTransform
+        tx: AffineTransform,
+        descriptor: Identifier
     )(implicit re: Renderer[ShapeElem, R], rt: Renderer[TextBox, R]): Unit = {
 
       val wX1 = data(xCol)
@@ -823,7 +894,8 @@ private[nspl] trait Renderers {
         xAxis: Axis,
         yAxis: Axis,
         ctx: R,
-        tx: AffineTransform
+        tx: AffineTransform,
+        descriptor: Identifier
     )(implicit re: Renderer[ShapeElem, R], rt: Renderer[TextBox, R]): Unit = {
 
       val wX = data(xCol)
diff --git a/core/src/main/scala/org/nspl/elements.scala b/core/src/main/scala/org/nspl/elements.scala
index d4464bc6..d5c9302a 100644
--- a/core/src/main/scala/org/nspl/elements.scala
+++ b/core/src/main/scala/org/nspl/elements.scala
@@ -140,12 +140,25 @@ case class ShapeElem(
 
 /** A Renderable describing a text box
 *
-* See the apply factory method in its companion object on how to construct one
+* See the apply factory method in its companion object on how to construct one.
+*
+* The five boolean style flags control attributed-string rendering. Backends
+* honor them where the underlying typography system supports it. The AWT
+* backend supports all five via `java.awt.font.TextAttribute`; the canvas
+* backend supports bold and oblique natively and emulates subscript /
+* superscript / underline with extra geometry. Defaults preserve historical
+* behavior.
  */
 class TextBox(
     val layout: TextLayout,
     val color: Color,
-    val tx: AffineTransform
+    val tx: AffineTransform,
+    val bold: Boolean = false,
+    val oblique: Boolean = false,
+    val subScript: Boolean = false,
+    val superScript: Boolean = false,
+    val underline: Boolean = false,
+    val identifier: Identifier = EmptyIdentifier
 )(implicit fc: FontConfiguration)
     extends Renderable[TextBox] {
 
@@ -155,10 +168,36 @@ class TextBox(
     if (layout.isEmpty) Bounds(0, 0, 0, 0)
     else tx.transform(layout.bounds)
 
-  def transform(tx: (Bounds, AffineTransform) => AffineTransform) =
-    new TextBox(layout = layout, color = color, tx = tx(bounds, this.tx))
-  def transform(tx: AffineTransform) =
-    new TextBox(layout = layout, color = color, tx = tx.applyBefore(this.tx))
+  private def copy(tx: AffineTransform): TextBox =
+    new TextBox(
+      layout,
+      color,
+      tx,
+      bold,
+      oblique,
+      subScript,
+      superScript,
+      underline,
+      identifier
+    )
+
+  def transform(tx: (Bounds, AffineTransform) => AffineTransform): TextBox =
+    copy(tx = tx(bounds, this.tx))
+  def transform(tx: AffineTransform): TextBox =
+    copy(tx = tx.applyBefore(this.tx))
+
+  def withIdentifier(id: Identifier): TextBox =
+    new TextBox(
+      layout,
+      color,
+      tx,
+      bold,
+      oblique,
+      subScript,
+      superScript,
+      underline,
+      id
+    )
 }
 
 object TextBox {
@@ -167,7 +206,23 @@ object TextBox {
       width: Option[Double] = None,
       fontSize: RelFontSize = 1 fts,
       color: Color = Color.black,
-      tx: AffineTransform = AffineTransform.identity
+      tx: AffineTransform = AffineTransform.identity,
+      bold: Boolean = false,
+      oblique: Boolean = false,
+      subScript: Boolean = false,
+      superScript: Boolean = false,
+      underline: Boolean = false,
+      identifier: Identifier = EmptyIdentifier
   )(implicit fc: FontConfiguration): TextBox =
-    new TextBox(TextLayout(width, text, fontSize), color, tx)
+    new TextBox(
+      TextLayout(width, text, fontSize),
+      color,
+      tx,
+      bold,
+      oblique,
+      subScript,
+      superScript,
+      underline,
+      identifier
+    )
 }
diff --git a/core/src/main/scala/org/nspl/events.scala b/core/src/main/scala/org/nspl/events.scala
index bab8545d..7cdf25ba 100644
--- a/core/src/main/scala/org/nspl/events.scala
+++ b/core/src/main/scala/org/nspl/events.scala
@@ -39,7 +39,58 @@ private[nspl] trait Events {
   case class Drag(start: Point, current: Point, plotArea: PlotAreaIdentifier)
       extends Event
 
+  /** A rectangular selection (shift+drag) over a plot area. `start` and
+    * `current` are the two corners in canvas coordinates.
+    *
+    * @param plotArea
+    *   identifies which plot area was selected. The bounds member of the
+    *   identifier must be defined.
+    */
+  case class Selection(
+      start: Point,
+      current: Point,
+      plotArea: PlotAreaIdentifier
+  ) extends Event
+
   /* The event representing the first build (before any user interaction happened) of component */
   case object BuildEvent extends Event
 
 }
+
+/** Buffers a sequence of events that, when replayed against a fresh `Build`,
+  * reproduces the current view. Consecutive Drag or Selection events on the
+  * same plot area are fused — that's the "fusion helper" part of the name —
+  * so the replay log stays compact (one cumulative drag rather than N
+  * incremental ones).
+  *
+  * This is consumed by the canvas backend's interactive `render`. It is
+  * exposed in core (rather than canvas) so it can be unit-tested on the JVM
+  * — its logic is pure Scala and does not depend on any DOM types.
+  */
+class EventFusionHelper {
+  private var buffer: Vector[Event] = Vector.empty
+
+  def clear(): Unit = buffer = Vector.empty
+  def get: Vector[Event] = buffer
+  def size: Int = buffer.size
+
+  def add(ev: Event, fusable: Boolean): Unit =
+    if (!fusable) buffer = buffer :+ ev
+    else
+      (buffer.lastOption, ev) match {
+        case (Some(Drag(s1, c1, a1)), Drag(s2, c2, a2))
+            if a1.id == a2.id && c1 == s2 =>
+          // consecutive incremental drags: collapse to the cumulative drag
+          buffer = buffer.dropRight(1) :+ Drag(s1, c2, a1)
+        case (Some(Drag(s1, _, a1)), Drag(s2, c2, a2))
+            if a1.id == a2.id && s1 == s2 =>
+          // same start point, growing endpoint: keep the latest endpoint
+          buffer = buffer.dropRight(1) :+ Drag(s1, c2, a1)
+        case (Some(Selection(s1, _, a1)), Selection(s2, c2, a2))
+            if a1.id == a2.id && s1 == s2 =>
+          // selection growing from a fixed corner
+          buffer = buffer.dropRight(1) :+ Selection(s1, c2, a1)
+        case _ =>
+          buffer = buffer :+ ev
+      }
+}
diff --git a/core/src/main/scala/org/nspl/plot.scala b/core/src/main/scala/org/nspl/plot.scala
index 0dff425f..4b32db2f 100644
--- a/core/src/main/scala/org/nspl/plot.scala
+++ b/core/src/main/scala/org/nspl/plot.scala
@@ -16,7 +16,9 @@ case class DataElem(
     yAxis: Axis,
     renderers: Seq[DataRenderer],
     originalBounds: Bounds,
-    tx: AffineTransform = AffineTransform.identity
+    tx: AffineTransform = AffineTransform.identity,
+    externalDataSourceIdx: Int = 0,
+    dataSourceIdx: Int = 0
 ) extends Renderable[DataElem] {
   def transform(tx: AffineTransform) =
     copy(tx = tx.applyBefore(this.tx))
@@ -34,10 +36,16 @@ object DataElem {
       rt: Renderer[TextBox, RC]
   ): Renderer[DataElem, RC] = new Renderer[DataElem, RC] {
     def render(r: RC, e: DataElem): Unit = {
-      e.data.iterator.foreach { row =>
+      var rowIdx = 0
+      val it = e.data.iterator
+      while (it.hasNext) {
+        val row = it.next()
+        val descriptor =
+          DataRowIdx(e.externalDataSourceIdx, e.dataSourceIdx, rowIdx)
         e.renderers.foreach { dr =>
-          dr.render(row, e.xAxis, e.yAxis, r, e.tx)
+          dr.render(row, e.xAxis, e.yAxis, r, e.tx, descriptor)
         }
+        rowIdx += 1
       }
       e.renderers.foreach(_.clear(r))
     }
@@ -261,6 +269,63 @@ private[nspl] trait Plots {
           xNoTickLabel,
           yNoTickLabel
         )
+
+      case (Some(old), Selection(selStart, selEnd, plotAreaId))
+          if plotAreaId.id == id =>
+        import old._
+        val startWorld = mapPoint(
+          selStart,
+          plotAreaId.bounds.get,
+          Bounds(xMin, yMin, xMax - xMin, yMax - yMin),
+          true
+        )
+        val endWorld = mapPoint(
+          selEnd,
+          plotAreaId.bounds.get,
+          Bounds(xMin, yMin, xMax - xMin, yMax - yMin),
+          true
+        )
+        val xMin1 = math.min(startWorld.x, endWorld.x)
+        val xMax1 = math.max(startWorld.x, endWorld.x)
+        val yMin1 = math.min(startWorld.y, endWorld.y)
+        val yMax1 = math.max(startWorld.y, endWorld.y)
+        // Guard against degenerate (single-point) selections that would
+        // collapse the axis range and break tick generation.
+        if (xMax1 - xMin1 <= 0 || yMax1 - yMin1 <= 0) old
+        else
+          xyplotarea(
+            id,
+            data,
+            xAxisSetting,
+            yAxisSetting,
+            origin,
+            Some(xMin1 -> xMax1),
+            Some(yMin1 -> yMax1),
+            xAxisMargin,
+            yAxisMargin,
+            xgrid,
+            ygrid,
+            frame,
+            xCustomGrid,
+            yCustomGrid,
+            main,
+            mainFontSize,
+            mainDistance,
+            xlab,
+            xlabFontSize,
+            xlabDistance,
+            xlabAlignment,
+            ylab,
+            ylabFontSize,
+            ylabDistance,
+            ylabAlignment,
+            topPadding,
+            bottomPadding,
+            leftPadding,
+            rightPadding,
+            xNoTickLabel,
+            yNoTickLabel
+          )
     }
   }
 
@@ -341,6 +406,9 @@ private[nspl] trait Plots {
       else if (yMinMax.isEmpty) 1d
       else yMinMax.map(_.max).max
 
+    val ln2 = math.log(2d)
+    def log2(x: Double) = math.log(x) / ln2
+
     val xMin = xAxisSetting.axisFactory match {
       case LinearAxisFactory =>
         math.min(
@@ -354,6 +422,11 @@ private[nspl] trait Plots {
           xLimMin.getOrElse(math.pow(10d, math.log10(dataXMin).floor)),
           origin.map(_.x).getOrElse(Double.MaxValue)
         )
+      case Log2AxisFactory =>
+        math.min(
+          xLimMin.getOrElse(math.pow(2d, log2(dataXMin).floor)),
+          origin.map(_.x).getOrElse(Double.MaxValue)
+        )
     }
 
     val xMax = xAxisSetting.axisFactory match {
@@ -372,6 +445,11 @@ private[nspl] trait Plots {
         if (xMax1 == xMin) {
           xMax1 + 1
         } else xMax1
+      case Log2AxisFactory =>
+        val xMax1 = xLimMax.getOrElse {
+          math.pow(2d, log2(dataXMax).ceil)
+        }
+        if (xMax1 == xMin) xMax1 + 1 else xMax1
 
     }
 
@@ -388,6 +466,11 @@ private[nspl] trait Plots {
           yLimMin.getOrElse(math.pow(10d, math.log10(dataYMin).floor)),
           origin.map(_.y).getOrElse(Double.MaxValue)
         )
+      case Log2AxisFactory =>
+        math.min(
+          yLimMin.getOrElse(math.pow(2d, log2(dataYMin).floor)),
+          origin.map(_.y).getOrElse(Double.MaxValue)
+        )
     }
 
     val yMax = yAxisSetting.axisFactory match {
@@ -405,6 +488,11 @@ private[nspl] trait Plots {
         if (yMax1 == yMin) {
           yMax1 + 1
         } else yMax1
+      case Log2AxisFactory =>
+        val yMax1 = yLimMax.getOrElse {
+          math.pow(2d, log2(dataYMax).ceil)
+        }
+        if (yMax1 == yMin) yMax1 + 1 else yMax1
     }
 
     val xAxis =
@@ -461,8 +549,18 @@ private[nspl] trait Plots {
       FreeLayout
     )
 
-    val dataelem = sequence(data.toList.map { case (ds, drs) =>
-      DataElem(ds, xAxis, yAxis, drs, axes.bounds, AffineTransform.identity)
+    val dataelem = sequence(data.toList.zipWithIndex.map {
+      case ((ds, drs), idx) =>
+        DataElem(
+          ds,
+          xAxis,
+          yAxis,
+          drs,
+          axes.bounds,
+          AffineTransform.identity,
+          externalDataSourceIdx = idx,
+          dataSourceIdx = idx
+        )
     })
 
     val xgridPoints =
diff --git a/core/src/test/scala/org/nspl/axis.test.scala b/core/src/test/scala/org/nspl/axis.test.scala
new file mode 100644
index 00000000..22bbee07
--- /dev/null
+++ b/core/src/test/scala/org/nspl/axis.test.scala
@@ -0,0 +1,72 @@
+package org.nspl
+
+class AxisSpec extends munit.FunSuite {
+
+  private val eps = 1e-9
+
+  private def roundTrip(axis: Axis, values: Seq[Double]): Unit =
+    values.foreach { v =>
+      val rt = axis.viewToWorld(axis.worldToView(v))
+      assert(
+        math.abs(rt - v) < eps * math.max(1.0, math.abs(v)),
+        s"worldToView ∘ viewToWorld($v) = $rt; horizontal=${axis.horizontal}, log=${axis.log}"
+      )
+    }
+
+  test("LinearAxisFactory horizontal: viewToWorld inverts worldToView") {
+    val ax = LinearAxisFactory.make(-10d, 30d, 200d, horizontal = true)
+    roundTrip(ax, Seq(-10d, -5d, 0d, 7.5, 12.345, 30d))
+  }
+
+  test("LinearAxisFactory vertical: viewToWorld inverts worldToView") {
+    val ax = LinearAxisFactory.make(0d, 100d, 400d, horizontal = false)
+    roundTrip(ax, Seq(0d, 25d, 50d, 99d, 100d))
+  }
+
+  test("Linear: view endpoints match width endpoints") {
+    val ax = LinearAxisFactory.make(0d, 50d, 100d, horizontal = true)
+    assertEqualsDouble(ax.worldToView(0d), 0d, eps)
+    assertEqualsDouble(ax.worldToView(50d), 100d, eps)
+    assertEqualsDouble(ax.viewToWorld(0d), 0d, eps)
+    assertEqualsDouble(ax.viewToWorld(100d), 50d, eps)
+  }
+
+  test("Linear vertical: y axis is inverted in view space") {
+    val ax = LinearAxisFactory.make(0d, 100d, 200d, horizontal = false)
+    // For a vertical axis y=min is drawn at the *bottom* (view = width),
+    // y=max at the top (view = 0).
+    assertEqualsDouble(ax.worldToView(0d), 200d, eps)
+    assertEqualsDouble(ax.worldToView(100d), 0d, eps)
+  }
+
+  test("Log10AxisFactory: viewToWorld inverts worldToView") {
+    val ax = Log10AxisFactory.make(1d, 1000d, 300d, horizontal = true)
+    roundTrip(ax, Seq(1d, 5d, 10d, 100d, 999d))
+    assert(ax.log)
+    assert(!ax.isLog2)
+  }
+
+  test("Log10: midpoint of view = geometric midpoint of world range") {
+    val ax = Log10AxisFactory.make(1d, 10000d, 100d, horizontal = true)
+    // log10(1)=0, log10(10000)=4, midpoint of log = 2 → world = 100.
+    assertEqualsDouble(ax.viewToWorld(50d), 100d, 1e-6)
+  }
+
+  test("Log2AxisFactory: viewToWorld inverts worldToView") {
+    val ax = Log2AxisFactory.make(1d, 64d, 200d, horizontal = true)
+    roundTrip(ax, Seq(1d, 2d, 4d, 8d, 16d, 32d, 64d))
+    assert(ax.log)
+    assert(ax.isLog2)
+  }
+
+  test("Log2 vertical: viewToWorld inverts worldToView") {
+    val ax = Log2AxisFactory.make(2d, 32d, 150d, horizontal = false)
+    roundTrip(ax, Seq(2d, 4d, 8d, 16d, 32d))
+  }
+
+  test("Log10 throws on non-positive worldToView input") {
+    val ax = Log10AxisFactory.make(1d, 100d, 100d, horizontal = true)
+    intercept[RuntimeException](ax.worldToView(0d))
+    intercept[RuntimeException](ax.worldToView(-1d))
+  }
+}
diff --git a/core/src/test/scala/org/nspl/bounds.test.scala b/core/src/test/scala/org/nspl/bounds.test.scala
new file mode 100644
index 00000000..22bd4abf
--- /dev/null
+++ b/core/src/test/scala/org/nspl/bounds.test.scala
@@ -0,0 +1,52 @@
+package org.nspl
+
+class BoundsSpec extends munit.FunSuite {
+
+  test("fromPoints accepts any corner ordering") {
+    val cases = List(
+      (Point(0d, 0d), Point(10d, 5d)),
+      (Point(10d, 5d), Point(0d, 0d)),
+      (Point(10d, 0d), Point(0d, 5d)),
+      (Point(0d, 5d), Point(10d, 0d))
+    )
+    cases.foreach { case (p1, p2) =>
+      val b = Bounds.fromPoints(p1, p2)
+      assertEqualsDouble(b.x, 0d, 0d)
+      assertEqualsDouble(b.y, 0d, 0d)
+      assertEqualsDouble(b.w, 10d, 0d)
+      assertEqualsDouble(b.h, 5d, 0d)
+    }
+  }
+
+  test("fromPoints of a single point produces a zero-area rectangle") {
+    val b = Bounds.fromPoints(Point(3d, 7d), Point(3d, 7d))
+    assertEqualsDouble(b.w, 0d, 0d)
+    assertEqualsDouble(b.h, 0d, 0d)
+    assert(b.contains(Point(3d, 7d)))
+  }
+
+  test("contains is inclusive of all four edges") {
+    val b = Bounds(0d, 0d, 10d, 4d)
+    assert(b.contains(Point(0d, 0d)))
+    assert(b.contains(Point(10d, 0d)))
+    assert(b.contains(Point(0d, 4d)))
+    assert(b.contains(Point(10d, 4d)))
+    assert(b.contains(Point(5d, 2d)))
+  }
+
+  test("contains rejects points outside") {
+    val b = Bounds(0d, 0d, 10d, 4d)
+    assert(!b.contains(Point(-0.1, 2d)))
+    assert(!b.contains(Point(10.1, 2d)))
+    assert(!b.contains(Point(5d, -0.1)))
+    assert(!b.contains(Point(5d, 4.1)))
+  }
+
+  test("centerX / centerY / maxX / maxY") {
+    val b = Bounds(2d, 4d, 10d, 6d)
+    assertEqualsDouble(b.centerX, 7d, 0d)
+    assertEqualsDouble(b.centerY, 7d, 0d)
+    assertEqualsDouble(b.maxX, 12d, 0d)
+    assertEqualsDouble(b.maxY, 10d, 0d)
+  }
+}
diff --git a/core/src/test/scala/org/nspl/events.test.scala b/core/src/test/scala/org/nspl/events.test.scala
new file mode 100644
index 00000000..0ec438a8
--- /dev/null
+++ b/core/src/test/scala/org/nspl/events.test.scala
@@ -0,0 +1,96 @@
+package org.nspl
+
+class EventFusionHelperSpec extends munit.FunSuite {
+
+  private def plotArea(canFuse: Boolean = true): PlotAreaIdentifier =
+    PlotAreaIdentifier(new PlotId, Some(Bounds(0, 0, 100, 100)), canFuse)
+
+  test("non-fusable events are appended verbatim") {
+    val s = new EventFusionHelper
+    val a = plotArea()
+    val e1 = Drag(Point(0, 0), Point(10, 10), a)
+    val e2 = Drag(Point(10, 10), Point(20, 20), a)
+    s.add(e1, fusable = false)
+    s.add(e2, fusable = false)
+    assertEquals(s.get, Vector(e1, e2))
+  }
+
+  test("consecutive incremental drags fuse into the cumulative drag") {
+    val s = new EventFusionHelper
+    val a = plotArea()
+    s.add(Drag(Point(0, 0), Point(10, 10), a), fusable = true)
+    s.add(Drag(Point(10, 10), Point(25, 30), a), fusable = true)
+    assertEquals(s.size, 1)
+    assertEquals(s.get.head, Drag(Point(0, 0), Point(25, 30), a))
+  }
+
+  test("drags with the same start collapse to the latest endpoint") {
+    val s = new EventFusionHelper
+    val a = plotArea()
+    s.add(Drag(Point(0, 0), Point(5, 5), a), fusable = true)
+    s.add(Drag(Point(0, 0), Point(50, 50), a), fusable = true)
+    assertEquals(s.size, 1)
+    assertEquals(s.get.head, Drag(Point(0, 0), Point(50, 50), a))
+  }
+
+  test("drags on different plot areas do not fuse") {
+    val s = new EventFusionHelper
+    val a = plotArea()
+    val b = plotArea()
+    s.add(Drag(Point(0, 0), Point(10, 10), a), fusable = true)
+    s.add(Drag(Point(10, 10), Point(20, 20), b), fusable = true)
+    assertEquals(s.size, 2)
+  }
+
+  test("selection grows from a fixed start corner") {
+    val s = new EventFusionHelper
+    val a = plotArea()
+    s.add(Selection(Point(0, 0), Point(10, 10), a), fusable = true)
+    s.add(Selection(Point(0, 0), Point(30, 40), a), fusable = true)
+    assertEquals(s.size, 1)
+    assertEquals(s.get.head, Selection(Point(0, 0), Point(30, 40), a))
+  }
+
+  test("selections that don't share a start corner stack up") {
+    val s = new EventFusionHelper
+    val a = plotArea()
+    s.add(Selection(Point(0, 0), Point(10, 10), a), fusable = true)
+    s.add(Selection(Point(5, 5), Point(15, 15), a), fusable = true)
+    assertEquals(s.size, 2)
+  }
+
+  test("scroll events are not fused with each other") {
+    val s = new EventFusionHelper
+    val a = plotArea()
+    s.add(Scroll(1.0, Point(10, 10), a), fusable = true)
+    s.add(Scroll(1.0, Point(10, 10), a), fusable = true)
+    assertEquals(s.size, 2)
+  }
+
+  test("a drag followed by a scroll keeps both") {
+    val s = new EventFusionHelper
+    val a = plotArea()
+    s.add(Drag(Point(0, 0), Point(5, 5), a), fusable = true)
+    s.add(Scroll(1.0, Point(5, 5), a), fusable = true)
+    assertEquals(s.size, 2)
+  }
+
+  test("canFuseEvents=false on the identifier doesn't itself prevent fusion") {
+    // The store doesn't read the flag — it is the *caller's* responsibility
+    // to pass `fusable = id.canFuseEvents` from the canvas event loop. This
+    // pins that contract down so a future refactor can't drift.
+    val s = new EventFusionHelper
+    val a = plotArea(canFuse = false)
+    s.add(Drag(Point(0, 0), Point(5, 5), a), fusable = false)
+    s.add(Drag(Point(5, 5), Point(10, 10), a), fusable = false)
+    assertEquals(s.size, 2)
+  }
+
+  test("clear empties the store") {
+    val s = new EventFusionHelper
+    val a = plotArea()
+    s.add(Drag(Point(0, 0), Point(5, 5), a), fusable = true)
+    s.clear()
+    assertEquals(s.size, 0)
+  }
+}
diff --git a/core/src/test/scala/org/nspl/histogram.test.scala b/core/src/test/scala/org/nspl/histogram.test.scala
new file mode 100644
index 00000000..a73dd820
--- /dev/null
+++ b/core/src/test/scala/org/nspl/histogram.test.scala
@@ -0,0 +1,58 @@
+package org.nspl
+
+import org.nspl.data.HistogramData
+
+class HistogramSpec extends munit.FunSuite {
+
+  test("density normalizes total area to 1") {
+    val hist = HistogramData(Seq(1d, 1d, 2d, 2d, 3d, 3d, 4d), 1.0)
+    val d = hist.density
+    val totalArea = d.bins.iterator.map {
+      case ((x1, x2, _), y) => (x2 - x1) * y
+    }.sum
+    assertEqualsDouble(totalArea, 1.0, 1e-9)
+  }
+
+  test("density on an already-normalized histogram is idempotent in shape") {
+    val hist = HistogramData(Seq(1d, 2d, 3d, 4d, 5d), 1.0)
+    val d1 = hist.density
+    val d2 = d1.density
+    // Same bin centers, same (relative) heights
+    val ks1 = d1.bins.keys.toSet
+    val ks2 = d2.bins.keys.toSet
+    assertEquals(ks1, ks2)
+    ks1.foreach { k =>
+      assertEqualsDouble(d1.bins(k), d2.bins(k), 1e-9)
+    }
+  }
+
+  test("density of an empty histogram is a no-op (avoids divide-by-zero)") {
+    val empty = HistogramData(Seq.empty[Double], 1.0)
+    val d = empty.density
+    assertEquals(d.bins, empty.bins)
+  }
+
+  test("toScatter drops zero-height bins") {
+    // bin (0,1) → 0 occurrences, bin (1,2) → 2, bin (2,3) → 0, bin (3,4) → 1
+    val hist = HistogramData(Seq(1d, 1d, 3d), 1.0)
+    val scatter = hist.toScatter
+    // Every emitted row should have a positive top (= ystart + height > 0).
+    scatter.foreach(row => assert(row._2 > 0, s"unexpected empty bin: $row"))
+    // We expect exactly the non-empty bins.
+    assertEquals(scatter.size, hist.bins.count(_._2 > 0))
+  }
+
+  test("toScatter is sorted by x ascending") {
+    val hist = HistogramData(Seq(5d, 1d, 3d, 1d, 4d), 1.0)
+    val xs = hist.toScatter.map(_._1)
+    assertEquals(xs.toList, xs.sorted.toList)
+  }
+
+  test("relative reweights by total count") {
+    val hist = HistogramData(Seq(1d, 1d, 2d, 2d, 3d), 1.0)
+    val rel = hist.relative
+    // Sum of all bin values should be ≤ 1 (since each value is now count/n)
+    val total = rel.bins.values.sum
+    assert(total >= 0.99 && total <= 1.01, s"sum was $total")
+  }
+}
diff --git a/project/metals.sbt b/project/metals.sbt
index ce00deb3..7fe2e8c3 100644
--- a/project/metals.sbt
+++ b/project/metals.sbt
@@ -3,6 +3,6 @@
 
 // This file enables sbt-bloop to create bloop config files.
 
-addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.9")
+addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.19")
 
 // format: on
diff --git a/shared-js/src/main/scala/org/nspl/font.scala b/shared-js/src/main/scala/org/nspl/font.scala
index 27ab7ede..15e54fa0 100644
--- a/shared-js/src/main/scala/org/nspl/font.scala
+++ b/shared-js/src/main/scala/org/nspl/font.scala
@@ -10,8 +10,17 @@ object svgFont {
 
 /* Code duplication! */
 object canvasFont {
-  def apply(f: Font) = s"${f.size}px ${f.name}"
+  def apply(f: Font): String = apply(f, bold = false, italic = false)
 
+  /** Canvas font string with optional bold/italic style prefixes. Sub/super
+    * script and underline are emulated by the caller via geometry, not the
+    * font string.
+    */
+  def apply(f: Font, bold: Boolean, italic: Boolean): String = {
+    val styleParts =
+      (if (italic) "italic " else "") + (if (bold) "bold " else "")
+    s"${styleParts}${f.size}px ${f.name}"
+  }
 }
 
 private[nspl] object CanvasGlyphMeasurer extends Font.GlyphMeasurer {