Skip to content

ChartGallery.kt

src/jvmMain/kotlin/afield/examples/visualization/ChartGallery.kt · 950 lines

package afield.examples.visualization

import afield.ui.visualization.CHART_BAD
import afield.ui.visualization.Chart
import afield.app.protocol.FLineClick
import afield.ui.visualization.ScaleKind
import afield.ui.visualization.chart
import afield.ui.output.getFLineClickCallback
import org.jetbrains.kotlinx.dataframe.api.dataFrameOf
import afield.ui.visualization.linesToHTML
import afield.ui.visualization.outputTo
import afield.ui.visualization.hexColor
import afield.ui.visualization.niceTicks
import afield.ui.visualization.signalPalette
import afield.math.linalg.Vec4
import afield.graphics.render.label
import afield.graphics.render.thickness
import afield.math.linalg.vec
import afield.ui.output.toIntersection
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.ImageInfo
import org.jetbrains.skia.Surface
import java.io.File
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.hypot
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt

FIELD_TEST id: visualization.chart-gallery runner: jvm-main entrypoint: afield.examples.visualization.ChartGallery tags: [smoke, visualization, chart, fline, capture, cpu] timeoutSec: 180 expect: exitCode: 0 stdoutContains: - "chart gallery rendered" - "hit record"

object ChartGallery

Headless capture harness for Chart — the vehicle that takes the primitive all the way to pixels.

A chart that compiles is not a chart that is right: ticks can land on ugly numbers, labels can smear or vanish, data can be drawn outside its own frame, and none of that fails a type check. So this renders every mark kind through the real path — Chart.toFLines() → Skia's outputTo on a raster surface → PNG — and then asserts the things a reader would otherwise have to check by eye:

  • every figure is inked well above a floor, in the plot and in the label gutters, so a chart that lost its axis text cannot pass;
  • a counterfactual: the same chart re-rendered with different data must differ by at least MIN_COUNTERFACTUAL_PIXELS pixels, which is what stops blank-versus-blank passing at zero differences;
  • the data→pixel mapping puts the extremes on the plot edges, to a pixel;
  • the interactive path is live: the emitted markup carries one registered callback per bar, and invoking one through FLineClick.toIntersection() — the same conversion Server.kt's fline-click handler uses — delivers the right datum to Chart.onPick. What this does not cover is Chromium deciding which <g> a click landed in; tools/fline_hit_proof.py covers that half for FLineHitInspectorFigure, and the markup here is built by the same buildInteractiveSVG.

Rendering is CPU-only Skia with no AWT surface, no GPU and no Field window, so it runs in a test and in a docs build. Pass an output directory as args[0] (or set CHART_GALLERY_DIR) to regenerate committed captures; with neither it writes to a temp dir and commits nothing, which is what a test in a shared worktree has to do.

object ChartGallery {

Background of every capture — the dark the rest of the docs figures use.

    private const val BACKGROUND = 0xFF12151C.toInt()

Inked-pixel floor, as a fraction of the figure. Below this something did not draw.

Set just under the sparsest figure here — the log-log one — with the rest at 23–36%. A floor of "more than nothing" would have passed a chart that lost every mark but its axes.

Re-cut when the default aspect changed from 640x380 to 900x420, and the arithmetic is the reason to trust the re-cut rather than suspect it. The sparsest figure went 5.7% -> 4.30%, which fires a 4.5% floor. Nothing stopped drawing: the canvas grew 243,200 -> 378,000 px (x1.554), so that figure's ABSOLUTE ink went UP, ~13,862 -> ~16,254 px. A fraction-of-canvas floor is sensitive to canvas size in a way the property it guards is not; the ratio to the sparsest measurement is held at the original 0.79 (0.045/0.057), giving 0.79 x 4.30% = 3.4%.

    private const val MIN_INK_FRACTION = 0.034

How many pixels a counterfactual must move. Not "differs at all" — a real number.

Quartering a sine's amplitude measured 10193 moved pixels of 243200, so this is a floor near the measurement rather than a formality. Two blank images differ in zero pixels and would satisfy any "they are not identical" check.

    private const val MIN_COUNTERFACTUAL_PIXELS = 8_000

Inked pixels required in the strip left of the plot frame, where only y labels live.

    private const val MIN_LEFT_GUTTER_INK = 400

Inked pixels required below the plot frame: x labels, axis title, footnote.

    private const val MIN_BOTTOM_GUTTER_INK = 500

How many pixels sharing a grid's y domain must move.

Measured 20550 of 256000 over four panels a thousandfold apart, so this is a floor near the measurement. It is high on purpose: a grid that silently stopped sharing would still render four plausible panels, and only the amount of difference distinguishes that from working.

    private const val MIN_GRID_SHARING_PIXELS = 15_000

One named figure.

    private class Figure(val name: String, val build: () -> Chart)

the figures

source — 72 lines, from line 125
    private val FIGURES: List<Figure> = listOf(
        Figure("marks-lines-and-points") {
            val xs = (0..120).map { it / 120.0 * 6.28318 }
            val decay = xs.map { exp(-it * 0.25) * sin(it * 2.0) }
            val envelope = xs.map { exp(-it * 0.25) }
            Chart(
                title = "line, area and points share one frame",
                xLabel = "t (radians)",
                yLabel = "amplitude",
            )
                .area("envelope", xs, envelope)
                .line("damped sine", xs, decay)
                .points("samples", xs.filterIndexed { i, _ -> i % 8 == 0 },
                    decay.filterIndexed { i, _ -> i % 8 == 0 })
        },

        Figure("bars-categorical") {
            Chart(title = "bars put their categories on the axis", yLabel = "gates")
                .bars(
                    "cost",
                    listOf("xor", "maj3", "adder", "mux", "parity"),
                    listOf(6, 11, 27, 4, 19),
                )
        },

        Figure("histogram-sturges") {
            // A deterministic, visibly non-uniform sample: two Gaussians via Box-Muller on a fixed
            // LCG, so the shape is a real distribution and the picture is the same every run.
            var seed = 12345L
            fun next(): Double {
                seed = (seed * 6364136223846793005L + 1442695040888963407L)
                return ((seed ushr 11).toDouble() / (1L shl 53).toDouble())
            }
            val values = (0 until 600).map {
                val u1 = next().coerceAtLeast(1e-12)
                val u2 = next()
                val g = sqrt(-2.0 * ln(u1)) * cos(6.28318 * u2)
                if (it % 3 == 0) g * 0.6 + 3.0 else g
            }
            Chart(title = "histogram, bins by Sturges' rule", xLabel = "value", yLabel = "count")
                .histogram("samples", values)
        },

        Figure("log-scale-decades") {
            val n = (1..6).map { 10.0.pow(it) }
            val time = n.map { it * 1.4e-6 + 3e-4 }
            Chart(title = "a log axis, and decade ticks", xLabel = "n", yLabel = "seconds")
                .also { it.xKind = ScaleKind.LOG; it.yKind = ScaleKind.LOG }
                .line("measured", n, time)
                .points("measured", n, time)
        },

        Figure("expect-a-claim-you-can-watch-fail") {
            val iterations = (0..40).map { it }
            // A solver that converges but overshoots twice — the interesting case, because the
            // figure has to show the two failures rather than average them away.
            val residual = iterations.map { i ->
                val base = exp(-i * 0.22) * 1.5e-6
                if (i == 11 || i == 23) base + 2.4e-6 else base
            }
            Chart(
                title = "residual per iteration",
                xLabel = "iteration",
                yLabel = "|r|",
                note = "Adam, lr 0.05",
            )
                .line("residual", iterations, residual)
                .points("residual", iterations, residual)
                .expect(max = 1e-6, because = "converged below 1e-6")
        },

    )

A deterministic Gaussian sample: Box-Muller on a fixed LCG, so the figure never moves.

    private fun gaussian(n: Int, spread: Double, seed: Long): List<Double> {
        var state = seed
        fun next(): Double {
            state = state * 6364136223846793005L + 1442695040888963407L
            return (state ushr 11).toDouble() / (1L shl 53).toDouble()
        }
        return (0 until n).map {
            val u1 = next().coerceAtLeast(1e-12)
            val u2 = next()
            sqrt(-2.0 * ln(u1)) * cos(6.28318 * u2) * spread
        }
    }

entry point

fun main(args: Array<String>)

    @JvmStatic
    fun main(args: Array<String>) {
        System.setProperty("java.awt.headless", "true")
        val explicit = args.firstOrNull() ?: System.getenv("CHART_GALLERY_DIR")
        val dir = File(explicit ?: File(System.getProperty("java.io.tmpdir"), "chart-gallery").path)
        dir.mkdirs()

        checkTickSelection()

        FIGURES.forEach { figure ->
            val chart = figure.build()
            val raster = render(chart)
            val ink = inkFraction(raster)
            check(ink >= MIN_INK_FRACTION) {
                "${figure.name}: only ${"%.4f".format(ink)} of the figure is inked, floor is $MIN_INK_FRACTION — " +
                    "something did not draw"
            }
            checkGuttersInked(figure.name, chart, raster)
            // The sixth gate, applied to every committed figure too: nothing may leave the frame.
            frameProblems(figure.name, chart).forEach { error(it) }
            val file = File(dir, "chart-${figure.name}.png")
            file.writeBytes(raster.png)
            println(
                "  ${figure.name}: ${chart.marks.size} mark(s), ${chart.marks.sumOf { it.count }} points, " +
                    "ink ${"%.1f%%".format(ink * 100)}, " +
                    "frame overshoot ${"%.2f".format(frameOvershoot(chart))} px " +
                    "-> ${file.absolutePath}"
            )
        }

        checkCounterfactual(dir)
        checkPixelMapping()
        checkInteractivePath()
        checkNothingLeavesTheFrame(dir)
        checkPaletteSeparation()

        println("chart gallery rendered ${FIGURES.size + 1} figure(s) into ${dir.absolutePath}")
    }

assertions

The tick values every hand-rolled copy of this algorithm was supposed to produce.

Exact equality on purpose: niceTicks snaps its output to the step's own precision, so 0.30000000000000004 is a bug this catches rather than a floating-point fact to tolerate.

    private fun checkTickSelection() {
        fun expect(got: List<Double>, want: List<Double>, what: String) {
            check(got == want) { "niceTicks $what: got $got, wanted $want" }
        }
        // Exact `==` on Double, deliberately: these are the literals a label must be able to print
        // short, so `0.6000000000000001` sneaking in here is the bug and not a rounding detail.
        expect(niceTicks(0.0, 1.0, 6), listOf(0.0, 0.2, 0.4, 0.6, 0.8, 1.0), "over 0..1")
        expect(niceTicks(0.0, 47.0, 5), listOf(0.0, 10.0, 20.0, 30.0, 40.0), "over 0..47")
        expect(niceTicks(-3.0, 3.0, 6), listOf(-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0), "over -3..3")
        expect(niceTicks(0.0, 0.5, 6), listOf(0.0, 0.1, 0.2, 0.3, 0.4, 0.5), "over 0..0.5")
        check(niceTicks(5.0, 5.0, 6) == listOf(5.0)) { "a degenerate range must give one tick" }
        check(niceTicks(Double.NaN, 1.0, 6).isEmpty()) { "a NaN range must give no ticks" }
        val decades = afield.ui.visualization.logTicks(1.0, 1e6)
        check(decades.size == 7 && decades.first() == 1.0 && decades.last() == 1e6) {
            "log ticks over six decades should be the seven powers of ten, got $decades"
        }
        println("  niceTicks: 7 cases, exact")
    }

A figure whose data changed must look different, by a floored number of pixels.

The point of the floor: two blank images differ in zero pixels and would pass any "they are not identical" check that forgot to say by how much.

    private fun checkCounterfactual(dir: File) {
        val a = render(chart("signal" to (0..80).map { sin(it * 0.12) }, title = "counterfactual A"))
        val b = render(chart("signal" to (0..80).map { sin(it * 0.12) * 0.25 }, title = "counterfactual B"))
        check(a.width == b.width && a.height == b.height) { "counterfactual sizes differ" }
        var moved = 0
        for (i in a.pixels.indices) if (a.pixels[i] != b.pixels[i]) moved++
        check(moved >= MIN_COUNTERFACTUAL_PIXELS) {
            "quartering the amplitude moved only $moved pixels, floor is $MIN_COUNTERFACTUAL_PIXELS"
        }
        check(inkFraction(a) >= MIN_INK_FRACTION && inkFraction(b) >= MIN_INK_FRACTION) {
            "a counterfactual is worthless if either side is blank"
        }
        File(dir, "chart-counterfactual-a.png").writeBytes(a.png)
        File(dir, "chart-counterfactual-b.png").writeBytes(b.png)
        println("  counterfactual: $moved pixel(s) moved (floor $MIN_COUNTERFACTUAL_PIXELS)")
    }

The extremes of the data land on the extremes of the plot, to within a pixel.

The one arithmetic claim a chart makes about itself. Read off the drawn geometry rather than from a Scale built here, so it is the figure's mapping being checked and not a copy of it.

    private fun checkPixelMapping() {
        val chart = Chart().also { it.width = 400.0; it.height = 300.0; it.legend = false }
            .line("ramp", (0..10).map { it }, (0..10).map { it })
        chart.yDomain = 0.0 to 10.0
        chart.xDomain = 0.0 to 10.0
        val ramp = chart.toFLines().first { it.label == "ramp" }
        val points = ramp.nodes.mapNotNull { it.toOrNull }
        check(points.size == 11) { "the ramp should have 11 nodes, has ${points.size}" }
        val first = points.first()
        val last = points.last()
        // Left-to-right and bottom-to-top: y grows *down* in the 2D outputs, so the last point of a
        // rising ramp must have the *smaller* y. Getting this backwards is the classic silent flip.
        check(last.x > first.x) { "x must increase left to right, got ${first.x} -> ${last.x}" }
        check(last.y < first.y) { "a rising ramp must go up the screen, got ${first.y} -> ${last.y}" }
        // Uniform data on a linear scale must be uniformly spaced, to a pixel.
        val steps = points.zipWithNext { a, b -> (b.x - a.x).toDouble() }
        val spread = steps.maxOrNull()!! - steps.minOrNull()!!
        check(spread < 1e-3) { "a linear scale must space equal steps equally, spread was $spread" }
        val inside = points.all { it.x >= 0f && it.x <= 400f && it.y >= 0f && it.y <= 300f }
        check(inside) { "every drawn point must be inside the figure" }
        println(
            "  mapping: 11 nodes, x ${"%.1f".format(first.x)}..${"%.1f".format(last.x)}, " +
                "y ${"%.1f".format(first.y)}..${"%.1f".format(last.y)}, spacing spread ${"%.2e".format(spread)}"
        )
    }

Every bar is separately live, and one of them, invoked, reports the right datum.

This walks the production consumer: linesToHTML is what Box.lines calls, and the callbacks it registers are looked up and fired through the same FLineClick.toIntersection() the server's fline-click route uses.

source — 41 lines, from line 342
    private fun checkInteractivePath() {
        val categories = listOf("xor", "maj3", "adder", "mux", "parity")
        val values = listOf(6, 11, 27, 4, 19)
        var picked: String? = null
        val chart = Chart(title = "clickable bars").bars("cost", categories, values)
        chart.onPick = { pick -> picked = "${pick.series}/${pick.category}=${pick.y}@${pick.index}" }

        val html = linesToHTML(chart.toFLines(), chart.width, chart.height)
        val ids = Regex("""data-fline-callback-id="([^"]+)"""").findAll(html).map { it.groupValues[1] }.toList()
        check(ids.size == categories.size) {
            "expected one live callback per bar (${categories.size}), the markup carries ${ids.size}"
        }
        check("__flineHoverIn" in html) { "a hoverable mark must emit the hover hooks" }
        check(categories.all { it in html }) { "every category label must reach the markup" }

        // The third bar. Callback ids are emitted in draw order, so index 2 is "adder"/27.
        val wire = FLineClick(
            callbackId = ids[2],
            closestVertexIndex = 1,
            x = 210.0, y = 120.0,
            kind = "face",
            lineIndex = 2,
            label = "cost: adder → 27",
            nodeIndex = 1,
            subPathIndex = 0,
            elementIndex = 0,
            faceCoordinates = "bilinear",
            u = 0.5, v = 0.5,
        )
        val callback = getFLineClickCallback(ids[2])
        check(callback != null) { "the callback the markup names is not registered" }
        val record = wire.toIntersection()
        callback(record)
        check(picked == "cost/adder=27.0@2") { "a click on the third bar reported `$picked`" }
        println(
            "  hit record: kind=${record.kind} label=\"${record.label}\" line=${record.lineIndex} " +
                "node=${record.nodeIndex} u=${record.u} v=${record.v} faces=${record.faceCoordinates} " +
                "-> onPick saw $picked"
        )
        println("  interactive markup: ${ids.size} live callback(s), ${html.length} bytes")
    }

the sixth gate: a figure has to fit inside the figure

How far outside its declared width x height a figure's geometry may reach, in pixels.

Not zero: glyph outlines overshoot their advance box by a fraction of a pixel, a stroked axis is drawn on the frame line itself, and fast2DBounds is a bounding box over Bézier control points rather than over the curve. Measured over the five committed gallery figures, the worst overshoot is under a pixel, so 4 px is a floor with room and still four hundred times smaller than the failure it is here to catch.

    private const val FRAME_SLOP = 4.0

Five green gates shipped sharp_edge.png — an empty olive rectangle with the real plot as an illegible stamp in one corner. This is the sixth, and it is the one that would have caught it.

The mechanism, which nothing in a build makes visible: a Chart declares a width x height frame, Chart.toHTML hands the lines to linesToHTML, and that stamps the geometry's own union bounds onto the wrapper as data-afield-bounds-*. The box output panel and the docs capture then fit those bounds. So any geometry outside the declared frame does not get cropped, it shrinks everything else. Two ways to produce it, both of which shipped:

  • data outside an explicit xDomain/yDomain, which used to be mapped and drawn wherever it landed — the tetgen corner figure put a cross section 2 x 3 across into a 0.24 x 0.24 window and the outline landed thousands of pixels off-frame;
  • a long note, which used to be one unwrapped line running off the right edge.

Each case is measured twice. Once for the fix — the figure's bounds are inside the frame, and the mark geometry is inside plotRect — and once for the counterfactual: the same numbers mapped through the figure's own scales without the clip must land far outside, and the note unwrapped must be far too wide. Without that second half a future refactor that quietly disabled the clip would leave a test that still passes because the input became harmless.

source — 156 lines, from line 418
    private fun checkNothingLeavesTheFrame(dir: File) {
        val problems = mutableListOf<String>()

        // ── (i) data outside an explicit domain ───────────────────────────────────
        // The tetgen corner, reduced to its arithmetic: a closed 2 x 3 outline charted into a
        // 0.24 x 0.24 window on one of its corners.
        val outline = listOf(
            -1.0 to -1.5, 1.0 to -1.5, 1.0 to 1.5, -1.0 to 1.5, -1.0 to -1.5,
        )
        val corner = Chart(
            title = "one corner of a 2 x 3 outline",
            xLabel = "x", yLabel = "y",
        ).line("outline", outline.map { it.first }, outline.map { it.second })
        corner.width = 780.0
        corner.height = 440.0
        corner.xDomain = 0.82 to 1.06
        corner.yDomain = 1.32 to 1.56

        val plot = corner.plotRect()
        // The counterfactual, computed from the figure's own layout so it cannot drift out of step:
        // where the unclipped outline *would* land. If this is not far outside, the case is a
        // tautology and the assertion below proves nothing.
        val sx = afield.ui.visualization.Scale(0.82, 1.06, plot.x, plot.x + plot.w)
        val sy = afield.ui.visualization.Scale(1.32, 1.56, plot.y + plot.h, plot.y)
        val rawExcursion = outline.maxOf { (x, y) ->
            val px = sx.toPixel(x)
            val py = sy.toPixel(y)
            maxOf(plot.x - px, px - (plot.x + plot.w), plot.y - py, py - (plot.y + plot.h), 0.0)
        }
        if (rawExcursion < 2_000.0) {
            problems += "the corner counterfactual is toothless: unclipped, the outline would only " +
                "reach ${"%.0f".format(rawExcursion)} px outside a ${plot.w.toInt()}x${plot.h.toInt()} " +
                "plot, and the case no longer reproduces the defect it is here for"
        }
        problems += frameProblems("corner-window", corner)
        problems += markProblems("corner-window", corner, "outline")
        println(
            "  corner-window: unclipped the outline would reach ${"%.0f".format(rawExcursion)} px " +
                "outside the plot; drawn, ${extentReport(corner)}"
        )

        // ── (ii) a long note ──────────────────────────────────────────────────────
        // Verbatim the note the tetgen corner figure ships with, because that is the one that is
        // published wrong today.
        val longNote = "a 0.24 x 0.24 window on a body 2 x 3 across. The true corner is at " +
            "(1.0, 1.5); every level set cuts it off, and halving h halves what is left."
        val noted = Chart(title = "a long note", xLabel = "x", yLabel = "y", note = longNote)
            .line("ramp", (0..10).map { it }, (0..10).map { it })
        noted.width = 780.0
        noted.height = 440.0

        val oneLineWidth = afield.ui.visualization.chartLabelWidth(longNote)
        val room = noted.width - noted.plotRect().x - 12.0
        if (oneLineWidth < room + 200.0) {
            problems += "the long-note counterfactual is toothless: on one line the note measures " +
                "${"%.0f".format(oneLineWidth)} px against ${"%.0f".format(room)} px of room, which " +
                "is not enough overhang to prove the wrap did anything"
        }
        problems += frameProblems("long-note", noted)
        println(
            "  long-note: ${longNote.length} chars, ${"%.0f".format(oneLineWidth)} px on one line " +
                "against ${"%.0f".format(room)} px of room; drawn, ${extentReport(noted)}"
        )

        // ── (iii) a note nobody typed ─────────────────────────────────────────────
        // The survey's most interesting hit: `DataFrameCharts.chart` *generates* a footnote, and on
        // a categorical x with spare numeric columns it generates one of unbounded length out of the
        // column names. No caller wrote it, no reviewer saw it, and it is reachable from
        // `showChart(frame)` — one word.
        val frame = dataFrameOf(
            "component", "leakage current", "quiescent draw", "peak inrush", "settling time",
        )(
            "regulator", 0.4, 12.0, 320.0, 1.8,
            "level shifter", 1.1, 3.0, 41.0, 0.3,
            "charge pump", 0.7, 21.0, 512.0, 4.4,
        )
        // `x = "component"` and not auto-selection: with two or more numeric columns the auto path
        // picks a *numeric* x, and the branch that generates the long footnote is the categorical
        // one. Naming the categorical column is how a caller reaches it, and it is the obvious thing
        // to write for a frame shaped like this.
        val generated = frame.chart(x = "component", title = "a note the library wrote")
        generated.width = 780.0
        generated.height = 440.0
        val generatedNote = generated.note ?: ""
        val generatedWidth = afield.ui.visualization.chartLabelWidth(generatedNote)
        if (generatedWidth <= generated.width) {
            problems += "the generated-note case is toothless: DataFrameCharts produced only " +
                "${"%.0f".format(generatedWidth)} px of footnote on a ${generated.width.toInt()} px " +
                "frame, so it no longer demonstrates a note nobody typed running off the edge"
        }
        problems += frameProblems("generated-note", generated)
        println(
            "  generated-note: DataFrameCharts wrote ${generatedNote.length} chars, " +
                "${"%.0f".format(generatedWidth)} px on one line; drawn, ${extentReport(generated)}"
        )

        // ── (iv) a log x axis with a datum that is not on it ──────────────────────
        // Found by reading rather than by report: `resolvedYDomain` drops a non-positive datum on a
        // log axis and `resolvedXDomain` did not, so one zero in x dragged the domain to hi/100 and
        // put every small positive datum off the left of the frame — no explicit domain required.
        val logged = Chart(title = "a log x axis with a zero in it", xLabel = "n", yLabel = "t")
            .line("measured", listOf(0.0, 0.001, 0.1, 10.0, 1000.0), listOf(1.0, 2.0, 3.0, 4.0, 5.0))
        logged.xKind = ScaleKind.LOG
        val (lx0, lx1) = logged.resolvedXDomain()
        if (lx0 > 0.001) {
            problems += "a log x domain of ${"%.2e".format(lx0)}..${"%.2e".format(lx1)} excludes the " +
                "0.001 datum, so it is drawn off the left of the axis"
        }
        problems += frameProblems("log-x-with-zero", logged)
        // The counterfactual, stated as the domain the unfiltered code actually produced —
        // `padDegenerate(0, 1000, LOG)` takes its `a = b / 100` path and gives 10..1000. Pinned as a
        // rendering rather than as a comment, so removing the filter fails here and not silently.
        val unfiltered = Chart(title = "the pre-fix domain", xLabel = "n", yLabel = "t")
            .line("measured", listOf(0.001, 0.1, 10.0, 1000.0), listOf(2.0, 3.0, 4.0, 5.0))
        unfiltered.xKind = ScaleKind.LOG
        unfiltered.xDomain = 10.0 to 1000.0
        unfiltered.clip = false
        val leftOfAxis = unfiltered.plotRect().x -
            (drawnBounds(unfiltered)?.x ?: unfiltered.plotRect().x)
        if (leftOfAxis < 400.0) {
            problems += "the log-x counterfactual is toothless: on the pre-fix domain 10..1000 the " +
                "0.001 datum lands only ${"%.0f".format(leftOfAxis)} px left of the axis"
        }
        println(
            "  log-x-with-zero: domain ${"%.2e".format(lx0)}..${"%.2e".format(lx1)}, " +
                "${extentReport(logged)}; on the pre-fix domain the smallest datum sat " +
                "${"%.0f".format(leftOfAxis)} px left of the axis"
        )

        // Both cases, before and after, as a *panel* would draw them — because "it compiles" is not
        // verification for anything visual, and the raw 780 x 440 raster is not what a reader saw.
        // The before half is `clip = false`, which is the behaviour these two defects were, so the
        // pair is a thing this harness re-derives rather than a screenshot of history.
        listOf("corner-window" to corner, "long-note" to noted).forEach { (name, chart) ->
            chart.clip = false
            val before = renderAsPanelWouldFit(chart)
            chart.clip = true
            val after = renderAsPanelWouldFit(chart)
            File(dir, "chart-clip-$name-before.png").writeBytes(before.first.png)
            File(dir, "chart-clip-$name-after.png").writeBytes(after.first.png)
            println(
                "  $name as a panel fits it: before ${"%.0f%%".format(before.second * 100)}, " +
                    "after ${"%.0f%%".format(after.second * 100)} of declared size"
            )
            if (after.second <= before.second * 1.5) {
                problems += "$name: the panel fit only went ${"%.2f".format(before.second)} -> " +
                    "${"%.2f".format(after.second)}, which is not a fix anyone would see"
            }
        }

        check(problems.isEmpty()) { "figures left their own frame:\n  - " + problems.joinToString("\n  - ") }
        println(
            "  frame gate: 4 regression figures + ${FIGURES.size} gallery figures inside their own " +
                "frames to within $FRAME_SLOP px"
        )
    }

is the default palette actually distinguishable?

The third cause of sharp_edge.png was reported as "four near-identical warm series colours". Measured rather than judged, and the report is wrong about the palette — it is right about that figure. Both numbers, and the one nobody was looking for, are below.

CIEDE2000 over the six signalPalette entries, 15 unordered pairs: min 23.81 (orange vs yellow), median 47.48, max 74.94. A working floor for a categorical palette is around 20–25, so the palette clears it, if only just at its weakest pair.

What actually happened on that page: its first series was hand-coloured vec(1.0, 0.72, 0.35) = #ffb859, which is ΔE*ab 13.91 from palette slot 1 #ff9e4a — the slot the second series then took, because an explicitly coloured mark still consumed one. Series three and four took green and yellow, and yellow is 23.81 from that orange. So three warm lines, and the cause is a hand-picked colour colliding with the palette rather than the palette colliding with itself. That slot accounting is fixed — see Chart.paletteSlotsUsed — so the collision is no longer reachable by hand-colouring one series and defaulting the rest.

And the one that was not being looked for, found by this measurement and since fixed: palette slot 5, pink #ff7a9e, was ΔE2000 12.24 from CHART_BAD when CHART_BAD was the soft #ff6b6b, with slot 1's orange a borderline 24.96 behind it. A six-series chart with an expect() claim recoloured its failing data to a colour a reader could not tell from its sixth series. CHART_BAD is now #e60000 and its nearest slot is 29.32 — see dev_docs/chart_bad_separation.md for why the colour that moved was CHART_BAD and not the palette, and for what it cost in re-rendered captures.

The numbers here are of the Vec4 constants, which are float32, and that is where the 0.01 disagreements in this file's history came from: vec(1.0, 0.42, 0.42) measures 12.24 while the #ff6b6b it renders to measures 12.25. Both are right, of different things; the assertions below are on the constants, because a constant is what a future edit would move.

source — 47 lines, from line 607
    private fun checkPaletteSeparation() {
        val labs = signalPalette.map { toLab(hexColor(it)) }
        val pairs = mutableListOf<Triple<Double, Int, Int>>()
        for (i in labs.indices) for (j in i + 1 until labs.size) {
            pairs += Triple(deltaE2000(labs[i], labs[j]), i, j)
        }
        val worst = pairs.minBy { it.first }
        check(worst.first >= MIN_PALETTE_SEPARATION) {
            "signalPalette slots ${worst.second} and ${worst.third} are only " +
                "${"%.2f".format(worst.first)} apart in CIEDE2000, floor is $MIN_PALETTE_SEPARATION — " +
                "two series in one figure would read as one"
        }

        val background = toLab(afield.ui.visualization.hexColor("#12151c"))
        val faintest = labs.minOf { deltaE2000(it, background) }
        check(faintest >= MIN_PALETTE_AGAINST_BACKGROUND) {
            "a palette colour is only ${"%.2f".format(faintest)} from the figure background, floor is " +
                "$MIN_PALETTE_AGAINST_BACKGROUND"
        }

        val badLab = toLab(CHART_BAD)
        val nearestToBad = labs.withIndex().minBy { deltaE2000(it.value, badLab) }
        val badGap = deltaE2000(nearestToBad.value, badLab)
        check(badGap >= MIN_PALETTE_AGAINST_BAD) {
            "signalPalette slot ${nearestToBad.index} is only ${"%.2f".format(badGap)} from " +
                "CHART_BAD in CIEDE2000, floor is $MIN_PALETTE_AGAINST_BAD — a chart with that many " +
                "series would recolour its failing data to something a reader cannot tell from its " +
                "last series, and being visible is the whole point of expect()"
        }

        // CHART_BAD earns this one by being the darkest thing the figure draws data in: getting it
        // clear of the palette above meant darkening it (L* 64.1 -> 47.9) as well as saturating it,
        // so the next move in that direction is the one that makes a failing datum invisible.
        val badVsBackground = deltaE2000(badLab, background)
        check(badVsBackground >= MIN_PALETTE_AGAINST_BACKGROUND) {
            "CHART_BAD is only ${"%.2f".format(badVsBackground)} from the figure background, floor " +
                "is $MIN_PALETTE_AGAINST_BACKGROUND — a failing datum would be invisible"
        }
        println(
            "  palette: ${signalPalette.size} colours, ${pairs.size} pairs, CIEDE2000 min " +
                "${"%.2f".format(worst.first)} (slots ${worst.second}/${worst.third}), median " +
                "${"%.2f".format(pairs.map { it.first }.sorted()[pairs.size / 2])}, faintest against " +
                "background ${"%.2f".format(faintest)}; CHART_BAD's nearest slot is " +
                "${nearestToBad.index} at ${"%.2f".format(badGap)} (floor $MIN_PALETTE_AGAINST_BAD), " +
                "and it sits ${"%.2f".format(badVsBackground)} off the background"
        )
    }

The weakest pair the default categorical palette is allowed to have. Measured at 23.81.

    private const val MIN_PALETTE_SEPARATION = 20.0

How far a series colour — or CHART_BAD — must sit from the dark figure background. Measured at 55.60 for the faintest palette slot and 45.72 for CHART_BAD.

    private const val MIN_PALETTE_AGAINST_BACKGROUND = 40.0

How far CHART_BAD must sit from every signalPalette slot. Measured at 29.32 against slot 1's orange, with the pink that was the collision now at 29.40.

25.0 sits above MIN_PALETTE_SEPARATION because a failing datum has to survive a reader who is not looking for it, where two series only have to be told apart from each other. It also sits just above the 24.96 the old #ff6b6b measured against that same orange, which is the pair the gallery's own expect figure was drawn in and which is legible as a collision in the version of it committed before this change. The 4.25 of headroom under 29.32 is so that recolouring a palette slot for its own reasons cannot redden this by a rounding error.

    private const val MIN_PALETTE_AGAINST_BAD = 25.0

sRGB in 0..1 to CIE Lab* under D65, the space the difference below is defined in.

    private fun toLab(c: Vec4): DoubleArray {
        fun linear(v: Float): Double {
            val u = v.toDouble().coerceIn(0.0, 1.0)
            return if (u <= 0.04045) u / 12.92 else ((u + 0.055) / 1.055).pow(2.4)
        }
        val r = linear(c.x); val g = linear(c.y); val b = linear(c.z)
        val x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047
        val y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750
        val z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883
        fun f(t: Double) = if (t > 216.0 / 24389.0) t.pow(1.0 / 3.0) else (841.0 / 108.0) * t + 4.0 / 29.0
        val fx = f(x); val fy = f(y); val fz = f(z)
        return doubleArrayOf(116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
    }

CIEDE2000, and not the Euclidean CIE76 it replaced, because CIE76 badly overstates the difference between two saturated colours of similar hue — which is exactly the case being judged here and exactly where it would have said the palette was fine.

source — 41 lines, from line 697
    private fun deltaE2000(a: DoubleArray, b: DoubleArray): Double {
        val (l1, a1, b1) = Triple(a[0], a[1], a[2])
        val (l2, a2, b2) = Triple(b[0], b[1], b[2])
        val c1 = hypot(a1, b1); val c2 = hypot(a2, b2)
        val cBar = (c1 + c2) / 2.0
        val g = 0.5 * (1 - sqrt(cBar.pow(7) / (cBar.pow(7) + 25.0.pow(7))))
        val a1p = (1 + g) * a1; val a2p = (1 + g) * a2
        val c1p = hypot(a1p, b1); val c2p = hypot(a2p, b2)
        fun hue(ap: Double, bp: Double) = if (ap == 0.0 && bp == 0.0) 0.0
        else (Math.toDegrees(atan2(bp, ap)) + 360.0) % 360.0
        val h1p = hue(a1p, b1); val h2p = hue(a2p, b2)
        val dLp = l2 - l1
        val dCp = c2p - c1p
        val dhp = when {
            c1p * c2p == 0.0 -> 0.0
            abs(h2p - h1p) <= 180.0 -> h2p - h1p
            h2p - h1p > 180.0 -> h2p - h1p - 360.0
            else -> h2p - h1p + 360.0
        }
        val dHp = 2 * sqrt(c1p * c2p) * sin(Math.toRadians(dhp) / 2.0)
        val lBar = (l1 + l2) / 2.0
        val cBarP = (c1p + c2p) / 2.0
        val hBarP = when {
            c1p * c2p == 0.0 -> h1p + h2p
            abs(h1p - h2p) <= 180.0 -> (h1p + h2p) / 2.0
            h1p + h2p < 360.0 -> (h1p + h2p + 360.0) / 2.0
            else -> (h1p + h2p - 360.0) / 2.0
        }
        val t = 1 - 0.17 * cos(Math.toRadians(hBarP - 30)) + 0.24 * cos(Math.toRadians(2 * hBarP)) +
            0.32 * cos(Math.toRadians(3 * hBarP + 6)) - 0.20 * cos(Math.toRadians(4 * hBarP - 63))
        val dTheta = 30 * exp(-((hBarP - 275) / 25).pow(2))
        val rc = 2 * sqrt(cBarP.pow(7) / (cBarP.pow(7) + 25.0.pow(7)))
        val sl = 1 + (0.015 * (lBar - 50).pow(2)) / sqrt(20 + (lBar - 50).pow(2))
        val sc = 1 + 0.045 * cBarP
        val sh = 1 + 0.015 * cBarP * t
        val rt = -sin(Math.toRadians(2 * dTheta)) * rc
        return sqrt(
            (dLp / sl).pow(2) + (dCp / sc).pow(2) + (dHp / sh).pow(2) +
                rt * (dCp / sc) * (dHp / sh)
        )
    }

The union of everything a chart draws, in figure pixels.

    private fun drawnBounds(chart: Chart): afield.app.protocol.Rect? =
        chart.toFLines().mapNotNull { runCatching { it.fast2DBounds() }.getOrNull() }
            .filter { it.x.isFinite() && it.y.isFinite() && it.w.isFinite() && it.h.isFinite() }
            .reduceOrNull { a, b -> a.union(b) }

How far outside its declared frame a figure's geometry reaches, in pixels; 0 if none does.

    private fun frameOvershoot(chart: Chart): Double {
        val b = drawnBounds(chart) ?: return 0.0
        return maxOf(-b.x, -b.y, (b.x + b.w) - chart.width, (b.y + b.h) - chart.height, 0.0)
    }

Empty if the whole figure is inside its declared frame, else one line saying by how much.

    private fun frameProblems(name: String, chart: Chart): List<String> {
        val b = drawnBounds(chart) ?: return listOf("$name: the figure drew nothing at all")
        val over = frameOvershoot(chart)
        if (over <= FRAME_SLOP) return emptyList()
        val shrink = minOf(chart.width / b.w, chart.height / b.h).coerceAtMost(1.0)
        return listOf(
            "$name: geometry reaches ${"%.0f".format(over)} px outside a " +
                "${chart.width.toInt()}x${chart.height.toInt()} frame — bounds " +
                "${"%.0f".format(b.x)},${"%.0f".format(b.y)} " +
                "${"%.0f".format(b.w)}x${"%.0f".format(b.h)}, so a panel fitting those bounds draws " +
                "this figure at ${"%.0f%%".format(shrink * 100)} of its declared size"
        )
    }

"" if every line of the named series is inside plotRect, else one line saying by how much.

    private fun markProblems(name: String, chart: Chart, series: String): List<String> {
        val plot = chart.plotRect()
        val lines = chart.toFLines().filter { it.label?.startsWith(series) == true }
        if (lines.isEmpty()) return listOf("$name: no line is labelled `$series`; the case did not draw")
        val over = lines.mapNotNull { runCatching { it.fast2DBounds() }.getOrNull() }.maxOf { b ->
            maxOf(plot.x - b.x, plot.y - b.y, (b.x + b.w) - (plot.x + plot.w), (b.y + b.h) - (plot.y + plot.h), 0.0)
        }
        if (over <= FRAME_SLOP) return emptyList()
        return listOf(
            "$name: series `$series` reaches ${"%.0f".format(over)} px outside plotRect " +
                "(${plot.x.toInt()},${plot.y.toInt()} ${plot.w.toInt()}x${plot.h.toInt()}), and " +
                "plotRect's own KDoc says the data is clipped to it"
        )
    }

    private fun extentReport(chart: Chart): String {
        val b = drawnBounds(chart) ?: return "nothing"
        return "bounds ${"%.0f".format(b.x)},${"%.0f".format(b.y)} " +
            "${"%.0f".format(b.w)}x${"%.0f".format(b.h)} in a " +
            "${chart.width.toInt()}x${chart.height.toInt()} frame"
    }

Ink outside the plot frame proves the labels drew, not only the data.

A chart that silently lost its glyph outlines still passes an overall ink floor, because the data is most of the ink. The gutters are where the tick labels and the title live and nothing else does, so they are checked separately.

    private fun checkGuttersInked(name: String, chart: Chart, raster: Raster) {
        // The real frame, not a guess: the insets come from the widest tick label, so a hard-coded
        // gutter would be measuring the wrong strip on half these figures.
        val plot = chart.plotRect()
        val left = plot.x.toInt()
        val below = (plot.y + plot.h).toInt() + 2
        val leftGutter = countInk(raster, 0, 0, left - 2, raster.height)
        val bottomGutter = countInk(raster, 0, below, raster.width, raster.height - below)
        check(leftGutter >= MIN_LEFT_GUTTER_INK) {
            "$name: the y tick labels did not draw ($leftGutter inked px, floor $MIN_LEFT_GUTTER_INK)"
        }
        check(bottomGutter >= MIN_BOTTOM_GUTTER_INK) {
            "$name: the x tick labels did not draw ($bottomGutter inked px, floor $MIN_BOTTOM_GUTTER_INK)"
        }
        // The title is drawn in the first palette colour, so its absence is detectable.
        if (chart.title != null) {
            val accent = countMatching(raster, hexOf(hexColor(signalPalette[0])), tolerance = 70)
            check(accent >= 30) { "$name: no palette-coloured ink; the title did not draw" }
        }
        // A claim, when one failed, must actually be visible: the band is drawn in CHART_BAD.
        if (chart.deviations.isNotEmpty()) {
            val bad = countMatching(raster, hexOf(CHART_BAD), tolerance = 46)
            check(bad >= 40) { "$name: ${chart.deviations.size} deviations but only $bad px of CHART_BAD" }
        }
        println("    $name: gutters left=$leftGutter bottom=$bottomGutter, plot=${plot.w.toInt()}x${plot.h.toInt()}")
    }

rasterising

internal fun staleCommittedFigures(dir: File): List<String>

The gallery figures in dir that no longer match what the chart code draws: missing, a different size, or more than one pixel in a thousand off by more than a few levels. The docs runner does not make these figures, so ChartTest asks this and fails until they are regenerated.

    internal fun staleCommittedFigures(dir: File): List<String> = FIGURES.mapNotNull { figure ->
        val file = File(dir, "chart-${figure.name}.png")
        if (!file.isFile) return@mapNotNull "${figure.name}: no committed figure"
        val now = render(figure.build())
        val image = org.jetbrains.skia.Image.makeFromEncoded(file.readBytes())
        val bitmap = Bitmap.makeFromImage(image)
        try {
            if (bitmap.width != now.width || bitmap.height != now.height)
                return@mapNotNull "${figure.name}: committed ${bitmap.width}x${bitmap.height}, drawn ${now.width}x${now.height}"
            var off = 0
            for (i in now.pixels.indices) {
                val a = now.pixels[i]
                val b = bitmap.getColor(i % now.width, i / now.width)
                val d = maxOf(abs(((a shr 16) and 255) - ((b shr 16) and 255)),
                    abs(((a shr 8) and 255) - ((b shr 8) and 255)), abs((a and 255) - (b and 255)))
                if (d > 3) off++
            }
            if (off * 1000 > now.pixels.size) "${figure.name}: $off pixels differ" else null
        } finally {
            runCatching { bitmap.close() }
            runCatching { image.close() }
        }
    }

One rendered figure: its pixels, and the PNG bytes for the same.

    private class Raster(val width: Int, val height: Int, val pixels: IntArray, val png: ByteArray)

A chart to real pixels: Skia's own outputTo, on a raster surface, antialiased.

outputTo with antiAlias = true because this is the final image — the SVG outputs leave antialiasing to the viewer, but a raster capture without it turns every glyph stem and diagonal into a staircase, which is exactly the detail a reader is being asked to judge.

    private fun render(chart: Chart): Raster =
        renderLines(chart.toFLines(), chart.width, chart.height)

A chart as the box output panel draws it, with the fit scale it chose.

render rasterises the figure's own coordinates onto a width x height surface, so anything off-frame is simply not in the picture and the failure is invisible. A panel does the opposite: linesToHTML stamps the geometry's union bounds on the wrapper and the panel scales those down to fit, which is why one stray point turns the axes into a stamp. This reproduces that arithmetic — the same 20 px safety inset and min of the two ratios as someSkiko.kt's computeFitTransform — so the captures show what a reader sees.

source — 72 lines, from line 878
    private fun renderAsPanelWouldFit(chart: Chart): Pair<Raster, Double> {
        val lines = chart.toFLines()
        val b = drawnBounds(chart) ?: return renderLines(lines, chart.width, chart.height) to 1.0
        val safety = 20.0
        val scale = minOf((chart.width - safety * 2) / b.w, (chart.height - safety * 2) / b.h)
        val placed = lines.map { line ->
            line.transform { p ->
                vec((p.x - b.x) * scale + safety, (p.y - b.y) * scale + safety, p.z)
            }.also { moved ->
                line.thickness?.let { moved.thickness = it * scale.coerceIn(0.0, 1.0) }
            }
        }
        return renderLines(placed, chart.width, chart.height) to scale
    }

    private fun renderLines(lines: List<afield.geometry.path.FLine>, wd: Double, ht: Double): Raster {
        val w = wd.toInt()
        val h = ht.toInt()
        val surface = Surface.makeRasterN32Premul(w, h)
        val bitmap = Bitmap()
        try {
            val canvas = surface.canvas
            canvas.clear(BACKGROUND)
            lines.forEach { outputTo(it, canvas, antiAlias = true) }
            val png = surface.makeImageSnapshot().encodeToData(EncodedImageFormat.PNG)?.bytes
                ?: error("Skia declined to encode the figure as PNG")
            bitmap.allocPixels(ImageInfo.makeN32Premul(w, h))
            check(surface.readPixels(bitmap, 0, 0)) { "Skia declined to read the figure back" }
            val pixels = IntArray(w * h) { bitmap.getColor(it % w, it / w) }
            return Raster(w, h, pixels, png)
        } finally {
            runCatching { bitmap.close() }
            runCatching { surface.close() }
        }
    }

    private fun inkFraction(r: Raster): Double =
        countInk(r, 0, 0, r.width, r.height).toDouble() / (r.width * r.height)

    private fun countInk(r: Raster, x0: Int, y0: Int, w: Int, h: Int): Int {
        var n = 0
        for (y in y0 until minOf(y0 + h, r.height)) {
            for (x in x0 until minOf(x0 + w, r.width)) {
                if (!nearBackground(r.pixels[y * r.width + x])) n++
            }
        }
        return n
    }

    private fun nearBackground(argb: Int): Boolean {
        val dr = abs(((argb shr 16) and 0xFF) - ((BACKGROUND shr 16) and 0xFF))
        val dg = abs(((argb shr 8) and 0xFF) - ((BACKGROUND shr 8) and 0xFF))
        val db = abs((argb and 0xFF) - (BACKGROUND and 0xFF))
        return dr + dg + db <= 12
    }

    private fun hexOf(c: Vec4): Int {
        fun ch(v: Float) = (v.coerceIn(0f, 1f) * 255f).toInt()
        return (ch(c.x) shl 16) or (ch(c.y) shl 8) or ch(c.z)
    }

    private fun countMatching(r: Raster, rgb: Int, tolerance: Int): Int {
        var n = 0
        for (p in r.pixels) {
            val dr = abs(((p shr 16) and 0xFF) - ((rgb shr 16) and 0xFF))
            val dg = abs(((p shr 8) and 0xFF) - ((rgb shr 8) and 0xFF))
            val db = abs((p and 0xFF) - (rgb and 0xFF))
            if (dr + dg + db <= tolerance) n++
        }
        return n
    }
}