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 @@ - +
+ +hover DataRow(...) + an
+ unhover when the mouse leaves it.
+ click DataRow(...).select N shapes → ....`. 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 {