From 4c2b338f79e5bc58eb41fa129803e8df12c374b3 Mon Sep 17 00:00:00 2001 From: Flavio Brasil Date: Tue, 9 Jun 2026 18:05:30 -0300 Subject: [PATCH] [ui] add the Chart layer: typed authoring spec lowering to SVG ## Problem kyo-ui can render typed SVG, but there is no way to author a data visualization from a dataset. Today that means hand-computing scales, axes, layout, and legends and emitting raw `Svg.*` shapes, with no type-driven mapping of data fields to visual marks and no reactive or animated updates. ## Solution Add `Chart`, a pure immutable authoring spec (not a `UI` node) that lowers to an `Svg.Root`. The dataset fixes the row type, so field accessors like `x = _.month` infer with no annotations, and a `Plottable` typeclass derives the right scale per field (band scales for enums, linear for numbers, time for instants). Marks (bar, line, area, point, text, error bar, rule) are the visual vocabulary; axes, legends, layout, stacking, theming, scales, interaction, and transitions are applied by refinement methods that return a refined `Chart`. Because `Svg.Root` is itself `HtmlContent`, a lowered chart drops into any HTML container, and a chart bound to reactive data animates between states via SMIL. The public surface is `Chart.scala`; the scale inference, axis, layout, legend, mark, interaction, and transition machinery lives in `kyo.internal`, so the reviewable API is small relative to the diff. ## Notes - SVG layer: `Animate` gains `calcMode`/`keyTimes`/`keySplines` so chart transitions can drive spline-interpolated keyframes. - Renderer correctness: the session id and base path are interpolated into a ` + | | |""".stripMargin @@ -246,6 +246,7 @@ private[kyo] object HtmlRenderer: attrs.ariaAttrs.toSeq.sortBy(_._1).foreach { case (name, value) => w(sb, s""" aria-$name="${esc(value)}"""") } + attrs.role.foreach(r => w(sb, s""" role="${esc(r)}"""")) attrs.dataAttrs.toSeq.sortBy(_._1).foreach { case (name, value) => w(sb, s""" data-$name="${esc(value)}"""") } @@ -526,6 +527,56 @@ private[kyo] object HtmlRenderer: sb.toString end esc + // Escape a string for safe embedding inside a JS double-quoted string literal within a + // (or etc.) ends the script element regardless of JS context. + // + // Rules applied, in order: + // \ -> \\ (backslash first, before any escape that produces \) + // " -> \" (closing double-quote) + // ' -> \' (single-quote, safe-by-default) + // \r -> \r (CR, JS line terminator) + // \n -> \n (LF, JS line terminator) + // U+2028 -> U+2028 (LINE SEPARATOR, JS line terminator) + // U+2029 -> U+2029 (PARAGRAPH SEPARATOR, JS line terminator) + // <\/ (prevents from closing the element; < alone is harmless in JS) + private def jsStr(s: String): String = + val sb = new StringBuilder(s.length) + @scala.annotation.tailrec + def loop(i: Int): Unit = + if i < s.length then + s.charAt(i) match + case '\\' => + sb.append("\\\\") + loop(i + 1) + case '"' => + sb.append("\\\"") + loop(i + 1) + case '\'' => + sb.append("\\'") + loop(i + 1) + case '\r' => + sb.append("\\r") + loop(i + 1) + case '\n' => + sb.append("\\n") + loop(i + 1) + case '
' => + sb.append("\\u2028") + loop(i + 1) + case '
' => + sb.append("\\u2029") + loop(i + 1) + case '<' if i + 1 < s.length && s.charAt(i + 1) == '/' => + sb.append("<\\/") + loop(i + 2) + case c => + sb.append(c) + loop(i + 1) + loop(0) + sb.toString + end jsStr + // ---- Client JS ---- private def clientJs(sessionId: String, basePath: String): String = @@ -570,7 +621,7 @@ private[kyo] object HtmlRenderer: | var ss=(ae&&typeof ae.selectionStart==='number')?ae.selectionStart:null; | var se=(ae&&typeof ae.selectionEnd==='number')?ae.selectionEnd:null; | el.outerHTML=op.Replace.html; - | var nel=document.querySelector('[data-kyo-path="'+p+'"]');if(nel)applyJsProps(nel); + | var nel=document.querySelector('[data-kyo-path="'+p+'"]');if(nel){applyJsProps(nel);ba(nel);} | if(ap){var rf=document.querySelector('[data-kyo-path="'+ap+'"]');if(rf&&rf.hasAttribute&&rf.hasAttribute('data-kyo-reactive')){var inner=rf.querySelector('input,textarea,select,[contenteditable]');if(inner)rf=inner;}if(rf){rf.focus();if(ss!==null&&typeof rf.setSelectionRange==='function'){try{rf.setSelectionRange(ss,se);}catch(e){if(e.name!=='InvalidStateError')throw e;}}}} | } | }else if(op.Remove){ @@ -643,7 +694,17 @@ private[kyo] object HtmlRenderer: | for(var k=0;k elements use + |// begin="indefinite" so they do not auto-play against the shared document timeline (which would + |// snap a post-load update to its frozen end value); beginElement() starts them relative to the + |// insertion. Deferred one frame so the SMIL engine has registered the new nodes. + |function ba(root){ + | if(!root||!root.querySelectorAll)return; + | var an=root.querySelectorAll("animate,animateTransform,animateMotion"); + | if(!an.length)return; + | requestAnimationFrame(function(){for(var i=0;i svgAttr(sb, "to", v)) s.animValues.foreach(v => svgAttr(sb, "values", v)) s.animDur.foreach(v => svgAttr(sb, "dur", v)) + s.animCalcMode.foreach(v => svgAttr(sb, "calcMode", v)) + s.animKeyTimes.foreach(v => svgAttr(sb, "keyTimes", v)) + s.animKeySplines.foreach(v => svgAttr(sb, "keySplines", v)) s.animRepeatCount.foreach(v => svgAttr(sb, "repeatCount", v)) s.animBegin.foreach(v => svgAttr(sb, "begin", v)) case _: Svg.AnimateTransform => diff --git a/kyo-ui/shared/src/main/scala/kyo/internal/Scale.scala b/kyo-ui/shared/src/main/scala/kyo/internal/Scale.scala new file mode 100644 index 0000000000..2db6254829 --- /dev/null +++ b/kyo-ui/shared/src/main/scala/kyo/internal/Scale.scala @@ -0,0 +1,492 @@ +package kyo.internal + +import kyo.Absent +import kyo.Chunk +import kyo.Maybe +import kyo.Present +import kyo.internal.NumberFormat + +/** A resolved mapping from a data domain to a pixel range, with its inverse and tick generator. + * + * Constructed by `Scale.fit` from a `Scale.Kind`, the data domain `Extent`, and the pixel range bounds. All + * implementations are pure; no effects. + * + * `apply` maps a domain value to a pixel coordinate; `invert` maps a pixel coordinate back to a domain value; + * `ticks` generates labeled tick positions; `bandwidth` returns the category band width (zero for continuous + * scales). + */ +sealed private[kyo] trait Scale: + def apply(d: Domain): Double + def invert(px: Double): Domain + def ticks(maxTicks: Int): Chunk[Scale.Tick] + def bandwidth: Double +end Scale + +/** The data domain before a scale is fitted: either a continuous numeric range or an ordered set of category keys. + * + * `Continuous(min, max)` is produced by folding numeric domain values. `Categories(keys)` is produced by folding + * categorical domain values in encounter order, deduplicated with order preserved. + */ +private[kyo] enum Extent derives CanEqual: + case Continuous(min: Double, max: Double) + case Categories(keys: Chunk[String]) +end Extent + +private[kyo] object Extent: + /** Construct a continuous extent over `[lo, hi]`. */ + def continuous(lo: Double, hi: Double): Extent = Extent.Continuous(lo, hi) + + /** Construct a categorical extent from an ordered key list. */ + def categories(keys: Chunk[String]): Extent = Extent.Categories(keys) +end Extent + +/** The scale domain coordinate produced by `Chart.Plottable.toDomain`. + * + * Three variants cover all scale families: `Continuous` for linear/log/time numeric axes, `Category` for band and + * ordinal axes, and `Temporal` for time axes (epoch milliseconds). Scales consume this union; the kind selects + * which variant is valid. + */ +private[kyo] enum Domain derives CanEqual: + case Continuous(value: Double) + case Category(key: String) + case Temporal(epochMillis: Long) +end Domain + +/** Companion object providing `Scale.Kind`, `Scale.Tick`, `Extent`, and the `fit` factory. + * + * `Scale.fit` is the single entry point: supply a `Kind`, the domain `Extent`, and the pixel range, and get back a + * fully resolved `Scale`. All six concrete implementations (`Linear`, `Log`, `Band`, `Time`, `Point`, + * `Symlog`) are produced exclusively through `fit`. + * + * `niceTicks` is exposed for direct use in tests and for axis tick generation at chart-build time. + */ +private[kyo] object Scale: + + /** Selects the scale family for an encoding. */ + enum Kind derives CanEqual: + case Linear, Log, Band, Time, Point, Symlog + end Kind + + /** A single tick: the domain value, a formatted label, and the pixel position on the range axis. + * + * `value` is the raw domain coordinate (continuous ticks carry the actual numeric value; time ticks carry + * epoch millis as Double; categorical ticks carry the zero-based category index). This field is what the + * `tickFormat` function in `AxisConfig` receives, so formatters always see the domain quantity, not the + * screen pixel. + */ + final case class Tick(value: Double, label: String, pixel: Double) derives CanEqual + + /** Construct a scale of `kind` over `extent`, mapping into `[rangeLo, rangeHi]` pixels. + * + * For continuous kinds (`Linear`, `Log`, `Time`), the extent supplies a numeric `(min, max)` pair. For + * categorical kinds (`Band`, `Point`), the extent supplies an ordered `Chunk[String]` of keys. The `nice` + * flag snaps continuous bounds to round values (the demos' niceTicks logic); it has no effect for categorical + * extents. + */ + def fit( + kind: Kind, + extent: Extent, + rangeLo: Double, + rangeHi: Double, + nice: Boolean = true, + clamp: Boolean = false + ): Scale = + kind match + case Kind.Linear => fitLinear(extent, rangeLo, rangeHi, nice, clamp) + case Kind.Log => fitLog(extent, rangeLo, rangeHi, clamp) + case Kind.Band => fitBand(extent, rangeLo, rangeHi) + case Kind.Time => fitTime(extent, rangeLo, rangeHi, nice) + case Kind.Point => fitPoint(extent, rangeLo, rangeHi) + case Kind.Symlog => fitSymlog(extent, rangeLo, rangeHi, clamp) + end fit + + /** Produce at most `maxTicks` evenly-spaced tick values covering `[min, max]`, snapped to a nice step. + * + * Degenerate inputs (`min == max` or `maxTicks <= 1`) return `Chunk(min)`. Every returned tick lies in + * `[min, max]`. The step is chosen from `{1, 2, 5} * 10^k` to minimise the number of steps while covering + * the range, following D3's nice-tick algorithm. + */ + def niceTicks(min: Double, max: Double, maxTicks: Int = 5): Chunk[Double] = + // Sort bounds so an inverted domain (min > max) cannot produce a negative rawStep, whose log10 would be + // NaN and corrupt every tick value. Ticks are always ascending (lo..hi); axis orientation is handled by + // the range mapping in Linear.apply, not by tick direction. + val lo = math.min(min, max) + val hi = math.max(min, max) + if maxTicks <= 1 || lo == hi then Chunk(lo) + else + val rawStep = (hi - lo) / (maxTicks - 1).toDouble + val magnitude = math.pow(10.0, math.floor(math.log10(rawStep))) + val residual = rawStep / magnitude + val niceUnit = + if residual <= 1.0 then 1.0 + else if residual <= 2.0 then 2.0 + else if residual <= 5.0 then 5.0 + else 10.0 + val step = niceUnit * magnitude + @scala.annotation.tailrec + def loop(i: Int, t: Double, acc: Chunk[Double]): Chunk[Double] = + if i >= maxTicks || t > hi + step * 1.0e-9 then acc + else loop(i + 1, t + step, acc.append(t)) + loop(0, lo, Chunk.empty) + end if + end niceTicks + + // ---- concrete scale implementations ---- + + /** A linear scale over a continuous `[domainMin, domainMax]` mapped to `[rangeLo, rangeHi]`. */ + final case class Linear( + domainMin: Double, + domainMax: Double, + rangeLo: Double, + rangeHi: Double, + clamp: Boolean = false, + niceStep: Maybe[Double] = Absent + ) extends Scale: + + def apply(d: Domain): Double = d match + case Domain.Continuous(v) => + if !v.isFinite then rangeLo + else if domainMax == domainMin then rangeLo + else + val t = (v - domainMin) / (domainMax - domainMin) + val out = rangeLo + t * (rangeHi - rangeLo) + if clamp then + val lo = math.min(rangeLo, rangeHi) + val hi = math.max(rangeLo, rangeHi) + if out < lo then lo else if out > hi then hi else out + else out + end if + case _: Domain.Category => rangeLo + case Domain.Temporal(ms) => apply(Domain.Continuous(ms.toDouble)) + + def invert(px: Double): Domain = + if rangeHi == rangeLo then Domain.Continuous(domainMin) + else + val t = (px - rangeLo) / (rangeHi - rangeLo) + Domain.Continuous(domainMin + t * (domainMax - domainMin)) + + def ticks(maxTicks: Int): Chunk[Tick] = + // Position ticks at domainMin + i*step (multiply by index, not accumulate) to avoid float drift, so + // the top tick lands exactly on domainMax when step divides the span. Callers pass a step chosen to + // divide the span evenly. + def emit(step: Double): Chunk[Tick] = + val count = math.max(0, math.round((domainMax - domainMin) / step).toInt) + Chunk.tabulate(count + 1): i => + val v = domainMin + i.toDouble * step + Tick(v, NumberFormat.double(v), apply(Domain.Continuous(v))) + end emit + niceStep match + case Present(fitStep) if fitStep > 0 => + // niceTicks honors maxTicks but may pick a step that does NOT land on domainMax over the + // widened snapped range (e.g. [0,250] -> step 100 -> top tick 200 != 250). fitStep divides + // the snapped range exactly (snappedHi = fitStep*ceil(hi/fitStep)). So when the + // maxTicks-honoring step already lands on domainMax, use it; otherwise fall back to the + // closest fitStep multiple, which is guaranteed to land on domainMax. + val req = niceTicks(domainMin, domainMax, maxTicks) + val reqStep = if req.size >= 2 then req(1) - req(0) else fitStep + val span = domainMax - domainMin + val eps = math.abs(fitStep) * 1.0e-9 + val reqLandsOnMax = + reqStep > 0 && math.abs(math.round(span / reqStep) * reqStep - span) <= eps + if reqLandsOnMax then emit(reqStep) + else + // k = number of fitStep intervals in the snapped span. Choose the multiplier m (>= 1) of + // fitStep whose interval count k/m is nearest the requested count, restricted to divisors + // of k so the top tick stays on domainMax. Ties prefer the smaller step (more ticks). + val k = math.max(1, math.round(span / fitStep).toInt) + val wantInt = math.max(1, maxTicks - 1) + @scala.annotation.tailrec + def bestDivisor(m: Int, best: Int): Int = + if m > k then best + else if k % m == 0 then + val curDist = math.abs(k / m - wantInt) + val bestDist = math.abs(k / best - wantInt) + bestDivisor(m + 1, if curDist < bestDist then m else best) + else bestDivisor(m + 1, best) + val m = bestDivisor(1, 1) + emit(fitStep * m.toDouble) + end if + case _ => + niceTicks(domainMin, domainMax, maxTicks).map: v => + Tick(v, NumberFormat.double(v), apply(Domain.Continuous(v))) + end match + end ticks + + def bandwidth: Double = 0.0 + end Linear + + /** A logarithmic (base-10) scale over `[domainMin, domainMax]` mapped to `[rangeLo, rangeHi]`. */ + final case class Log( + domainMin: Double, + domainMax: Double, + rangeLo: Double, + rangeHi: Double, + clamp: Boolean = false + ) extends Scale: + + private val logMin: Double = if domainMin > 0 then math.log10(domainMin) else 0.0 + private val logMax: Double = if domainMax > 0 then math.log10(domainMax) else 0.0 + + def apply(d: Domain): Double = d match + case Domain.Continuous(v) => + if !v.isFinite then rangeLo + else + val vc = if clamp then math.max(domainMin, math.min(domainMax, v)) else v + if vc <= 0 then rangeLo + else if logMax == logMin then rangeLo + else + val t = (math.log10(vc) - logMin) / (logMax - logMin) + val out = rangeLo + t * (rangeHi - rangeLo) + if clamp then + val lo = math.min(rangeLo, rangeHi) + val hi = math.max(rangeLo, rangeHi) + if out < lo then lo else if out > hi then hi else out + else out + end if + end if + case _: Domain.Category => rangeLo + case Domain.Temporal(ms) => apply(Domain.Continuous(ms.toDouble)) + + def invert(px: Double): Domain = + if rangeHi == rangeLo then Domain.Continuous(domainMin) + else + val t = (px - rangeLo) / (rangeHi - rangeLo) + Domain.Continuous(math.pow(10.0, logMin + t * (logMax - logMin))) + + def ticks(maxTicks: Int): Chunk[Tick] = + val lo = math.ceil(logMin).toInt + val hi = math.floor(logMax).toInt + @scala.annotation.tailrec + def loop(exp: Int, acc: Chunk[Tick]): Chunk[Tick] = + if exp > hi || acc.size >= maxTicks then acc + else + val v = math.pow(10.0, exp.toDouble) + loop(exp + 1, acc.append(Tick(v, NumberFormat.double(v), apply(Domain.Continuous(v))))) + loop(lo, Chunk.empty) + end ticks + + def bandwidth: Double = 0.0 + end Log + + /** A band scale mapping an ordered set of category keys to equal-width bands across `[rangeLo, rangeHi]`. + * + * Each band center is at `rangeLo + (i + 0.5) * slot` where `slot = (rangeHi - rangeLo) / n`. Inner + * padding of 0.1 of the slot is applied. + */ + final case class Band( + keys: Chunk[String], + rangeLo: Double, + rangeHi: Double, + padding: Double = 0.1 + ) extends Scale: + + val n: Int = keys.size + val totalW: Double = rangeHi - rangeLo + val slot: Double = if n <= 0 then totalW else totalW / n.toDouble + val bandW: Double = if n <= 0 then totalW else totalW * (1.0 - padding) / n.toDouble + private val keyIndex: Map[String, Int] = keys.zipWithIndex.foldLeft(Map.empty[String, Int]): + case (m, (k, i)) => m.updated(k, i) + + def apply(d: Domain): Double = d match + case Domain.Category(key) => + Maybe.fromOption(keyIndex.get(key)) match + case Absent => rangeLo + case Present(i) => + val xOffset = i.toDouble * slot + (slot - bandW) / 2.0 + rangeLo + xOffset + case Domain.Continuous(v) => + // Handle a numeric column forced to a Band scale via xScale(_.band): try the formatted value as a + // category key first (e.g. Continuous(2020.0) -> "2020") before treating v as an index. + val keyStr = NumberFormat.double(v) + Maybe.fromOption(keyIndex.get(keyStr)) match + case Present(i) => + val xOffset = i.toDouble * slot + (slot - bandW) / 2.0 + rangeLo + xOffset + case Absent => + // Fall back to treating v as a 0-based band index. + val i = v.toInt + if i >= 0 && i < n then + val xOffset = i.toDouble * slot + (slot - bandW) / 2.0 + rangeLo + xOffset + else rangeLo + end if + end match + case _: Domain.Temporal => rangeLo + + def invert(px: Double): Domain = + // Guard only empty scale or zero-width range, NOT a reversed range (rangeLo > rangeHi). A reversed + // range has slot < 0, which ((px - rangeLo) / slot).toInt handles correctly: numerator and + // denominator are both negative for a px inside the range, yielding a positive index. Guarding on + // totalW <= 0 would wrongly catch reversed ranges and short-circuit to the first key, so test + // slot == 0 instead. + if n <= 0 || slot == 0.0 then Domain.Category(if keys.isEmpty then "" else keys(0)) + else + val i = math.min(n - 1, math.max(0, ((px - rangeLo) / slot).toInt)) + Domain.Category(keys(i)) + + def ticks(maxTicks: Int): Chunk[Tick] = + val n = keys.size + // Carry each key's original index through the stride filter so pixel positions stay at the actual + // band centers, not at the filtered positions. + val visible: Chunk[(String, Int)] = + if maxTicks >= n then keys.zipWithIndex + else + val stride = math.max(1, math.ceil(n.toDouble / maxTicks).toInt) + keys.zipWithIndex.collect { case (k, i) if i % stride == 0 => (k, i) } + visible.map: (k, i) => + val xOffset = i.toDouble * slot + (slot - bandW) / 2.0 + bandW / 2.0 + Tick(i.toDouble, k, rangeLo + xOffset) + end ticks + + def bandwidth: Double = bandW + end Band + + /** A time scale: a linear scale over epoch-millisecond values. */ + final case class Time( + domainMin: Long, + domainMax: Long, + rangeLo: Double, + rangeHi: Double + ) extends Scale: + + private val inner: Linear = Linear(domainMin.toDouble, domainMax.toDouble, rangeLo, rangeHi) + + def apply(d: Domain): Double = d match + case Domain.Temporal(ms) => inner.apply(Domain.Continuous(ms.toDouble)) + case Domain.Continuous(v) => inner.apply(Domain.Continuous(v)) + case _: Domain.Category => rangeLo + + def invert(px: Double): Domain = inner.invert(px) match + case Domain.Continuous(v) => Domain.Temporal(v.toLong) + case other => other + + def ticks(maxTicks: Int): Chunk[Tick] = + val ts = niceTicks(domainMin.toDouble, domainMax.toDouble, maxTicks) + val step = if ts.size >= 2 then (ts(1) - ts(0)).toLong else (domainMax - domainMin) + ts.map: v => + Tick(v, TimeFormat.epochMillisLabel(v.toLong, step), apply(Domain.Temporal(v.toLong))) + end ticks + + def bandwidth: Double = 0.0 + end Time + + // ---- private fit helpers ---- + + private def fitLinear(extent: Extent, rangeLo: Double, rangeHi: Double, nice: Boolean, clamp: Boolean): Scale = + val (lo, hi) = extent match + case Extent.Continuous(mn, mx) => (mn, mx) + case Extent.Categories(keys) => (0.0, keys.size.toDouble - 1.0) + if nice then + val tks = niceTicks(lo, hi, 5) + val step = if tks.size >= 2 then tks(1) - tks(0) else 0.0 + // Snap the domain to step-aligned bounds (d3 scale.nice() semantics): floor lo and ceil hi to the + // nearest multiple of the nice step so the endpoints ARE ticks. Store that step on the scale + // (niceStep) for Linear.ticks to reuse: re-deriving niceTicks over the widened range could pick a + // different step that overshoots domainMax (e.g. [10,210] snaps to [0,250]; niceTicks(0,250) would + // step by 100 and overshoot). An already-aligned domain is unchanged (floor/ceil are no-ops). + if step > 0 then + val snappedLo = step * math.floor(lo / step) + val snappedHi = step * math.ceil(hi / step) + Linear(snappedLo, snappedHi, rangeLo, rangeHi, clamp, niceStep = Present(step)) + else Linear(lo, hi, rangeLo, rangeHi, clamp) // degenerate (lo == hi): niceStep Absent + end if + else Linear(lo, hi, rangeLo, rangeHi, clamp) + end if + end fitLinear + + private def fitLog(extent: Extent, rangeLo: Double, rangeHi: Double, clamp: Boolean): Scale = + val (lo, hi) = extent match + case Extent.Continuous(mn, mx) => (mn, mx) + case Extent.Categories(keys) => (1.0, keys.size.toDouble) + // lo is positive: non-positive values are filtered upstream in yLeftExtentNoZero. math.max(hi, lo) guards + // an empty domain after filtering, where hi can fall below lo. + Log(lo, math.max(hi, lo), rangeLo, rangeHi, clamp) + end fitLog + + private def fitBand(extent: Extent, rangeLo: Double, rangeHi: Double): Scale = + val keys = extent match + case Extent.Categories(ks) => ks + case Extent.Continuous(mn, mx) => + val lo = mn.toInt + val hi = mx.toInt + Chunk.from(lo.to(hi).map(_.toString)) + Band(keys, rangeLo, rangeHi) + end fitBand + + private def fitTime(extent: Extent, rangeLo: Double, rangeHi: Double, nice: Boolean): Scale = + val (lo, hi) = extent match + case Extent.Continuous(mn, mx) => (mn.toLong, mx.toLong) + case _: Extent.Categories => (0L, 1L) + Time(lo, hi, rangeLo, rangeHi) + end fitTime + + // Point scale: marks placed at band centers with zero inner band width. Reuses Band with padding = 0.5 so + // each slot's band collapses to a point. + private def fitPoint(extent: Extent, rangeLo: Double, rangeHi: Double): Scale = + val keys = extent match + case Extent.Categories(ks) => ks + case Extent.Continuous(mn, mx) => + val lo = mn.toInt + val hi = mx.toInt + Chunk.from(lo.to(hi).map(_.toString)) + Band(keys, rangeLo, rangeHi, padding = 0.5) + end fitPoint + + private def fitSymlog(extent: Extent, rangeLo: Double, rangeHi: Double, clamp: Boolean): Scale = + val (rawLo, rawHi) = extent match + case Extent.Continuous(mn, mx) => (mn, mx) + case _: Extent.Categories => (-1.0, 1.0) + Symlog(rawLo, rawHi, rangeLo, rangeHi, clamp) + end fitSymlog + + /** Symmetric-log scale for data spanning negative-to-positive values. + * + * Forward transform: f(v) = sign(v) * log10(1 + |v| / C), C=1 fixed. Exact algebraic inverse: g(u) = + * sign(u) * C * (10^|u| - 1). Finite at zero (f(0)=0), monotone, symmetric about zero. Ticks are + * generated in the transformed domain via niceTicks then mapped back through g. No public C knob; C=1 + * matches D3's default, log10 base gives readable power-of-10 labels. + */ + final case class Symlog( + domainMin: Double, + domainMax: Double, + rangeLo: Double, + rangeHi: Double, + clamp: Boolean + ) extends Scale: + private val C = 1.0 + private def f(v: Double): Double = math.signum(v) * math.log10(1.0 + math.abs(v) / C) + private def g(u: Double): Double = math.signum(u) * C * (math.pow(10.0, math.abs(u)) - 1.0) + private val fMin = f(domainMin) + private val fMax = f(domainMax) + + private def applyRaw(raw: Double): Double = + if fMax == fMin then rangeLo + else rangeLo + (f(raw) - fMin) / (fMax - fMin) * (rangeHi - rangeLo) + + def apply(d: Domain): Double = d match + case Domain.Continuous(v) => + if !v.isFinite then rangeLo + else if clamp then applyRaw(math.max(domainMin, math.min(domainMax, v))) + else applyRaw(v) + case Domain.Temporal(ms) => + val v = ms.toDouble + if clamp then applyRaw(math.max(domainMin, math.min(domainMax, v))) + else applyRaw(v) + case _: Domain.Category => applyRaw(domainMin) + end apply + + def invert(px: Double): Domain = + if rangeHi == rangeLo then Domain.Continuous(domainMin) + else + val t = (px - rangeLo) / (rangeHi - rangeLo) + Domain.Continuous(g(fMin + t * (fMax - fMin))) + + def ticks(maxTicks: Int): Chunk[Tick] = + niceTicks(fMin, fMax, maxTicks).map: u => + val v = g(u) + Tick(v, NumberFormat.double(v), apply(Domain.Continuous(v))) + + def bandwidth: Double = 0.0 + end Symlog + +end Scale diff --git a/kyo-ui/shared/src/main/scala/kyo/internal/SizeScale.scala b/kyo-ui/shared/src/main/scala/kyo/internal/SizeScale.scala new file mode 100644 index 0000000000..72503fe18d --- /dev/null +++ b/kyo-ui/shared/src/main/scala/kyo/internal/SizeScale.scala @@ -0,0 +1,22 @@ +package kyo.internal + +/** sqrt-area size scale: circle AREA (not radius) is proportional to the data magnitude, so + * `radius = rMin + (rMax - rMin) * sqrt(t)` where `t = clamp01((mag - magMin) / (magMax - magMin))`. + * + * When `magMax <= magMin` (degenerate extent: all magnitudes equal, or a single row), `radius` returns `rMin` + * for any input, avoiding a divide by zero. Build once from the full row set, then map per row. + */ +final private[kyo] case class SizeScale(magMin: Double, magMax: Double, rMin: Double, rMax: Double): + + /** Map a raw data magnitude to a circle radius. */ + def radius(mag: Double): Double = + if magMax <= magMin then rMin + else + val t = math.max(0.0, math.min(1.0, (mag - magMin) / (magMax - magMin))) + rMin + (rMax - rMin) * math.sqrt(t) + +end SizeScale + +private[kyo] object SizeScale: + val DefaultRMin = 2.0 + val DefaultRMax = 20.0 diff --git a/kyo-ui/shared/src/main/scala/kyo/internal/TimeFormat.scala b/kyo-ui/shared/src/main/scala/kyo/internal/TimeFormat.scala new file mode 100644 index 0000000000..3982b798b3 --- /dev/null +++ b/kyo-ui/shared/src/main/scala/kyo/internal/TimeFormat.scala @@ -0,0 +1,31 @@ +package kyo.internal + +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** Cross-platform temporal tick-label formatter. + * + * Formats epoch millis to a calendar label whose granularity is derived from the tick step: sub-day step uses + * HH:mm, day-to-month step uses yyyy-MM-dd, year-scale step uses yyyy. java.time works in shared source because + * scala-java-time supplies the JS/Native shim. + */ +private[kyo] object TimeFormat: + + private val hourMin = DateTimeFormatter.ofPattern("HH:mm") + private val isoDate = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private val yearFmt = DateTimeFormatter.ofPattern("yyyy") + + private val dayMillis = 86_400_000L + private val yearMillis = 365L * dayMillis + + /** Formats `ms` (epoch millis, UTC) at the granularity implied by `stepMillis`. */ + def epochMillisLabel(ms: Long, stepMillis: Long): String = + val fmt = + if stepMillis < dayMillis then hourMin + else if stepMillis < yearMillis then isoDate + else yearFmt + Instant.ofEpochMilli(ms).atZone(ZoneOffset.UTC).format(fmt) + end epochMillisLabel + +end TimeFormat diff --git a/kyo-ui/shared/src/test/scala/demo/BarChart.scala b/kyo-ui/shared/src/test/scala/demo/BarChart.scala deleted file mode 100644 index e4aa7a5ad1..0000000000 --- a/kyo-ui/shared/src/test/scala/demo/BarChart.scala +++ /dev/null @@ -1,306 +0,0 @@ -package demo - -import kyo.* -import kyo.Style.* -import kyo.UI.* -import scala.language.implicitConversions - -/** Animated bar chart built entirely on the kyo-ui SVG layer and served as a server-push app via `UI.runHandlers`. - * - * This is a reference for charting with the SVG API. Scales, ticks, and axes are pure helpers (`linearScale`, - * `bandScale`, `niceTicks`, `renderXAxis`, `renderYAxis`); every visual node is a typed `Svg.*` factory (`Svg.rect`, - * `Svg.line`, `Svg.text`, `Svg.animate`) with typed value DSLs (`ViewBox`, `Paint`, `SvgLength`). There is no raw - * markup and no string escape hatch. - * - * Two animation mechanisms are shown. (1) On load, each bar grows in from the baseline via a SMIL `Svg.animate` on the - * rect's `height` and `y`: the renderer emits the `` children and the browser drives the tween, so no - * server round-trips are needed. (2) The "Refresh data" control tweens the displayed values to a new same-length - * dataset through a `SignalRef`, stepped by `Async.sleep` over an eased interpolation (never blocks a thread), - * re-rendering the chart through the reactive boundary on each step. - * - * Run via `sbt 'kyo-ui/Test/runMain demo.BarChart'` (optional port as the first argument). - */ -object BarChart extends KyoApp: - - // ---- data ---- - - /** HTTP throughput by runtime (ops/s). Representative figures from a short `HttpServerBench` run, not a definitive - * benchmark; they exist to give the chart a meaningful shape. - */ - val httpThroughput: Chunk[(String, Double)] = Chunk( - ("kyo", 61_200.0), - ("cats", 49_800.0), - ("zio", 52_100.0) - ) - - /** A second same-length dataset the "Refresh data" control tweens to (and back), showing the value tween. */ - val httpThroughputAlt: Chunk[(String, Double)] = Chunk( - ("kyo", 58_400.0), - ("cats", 53_300.0), - ("zio", 47_900.0) - ) - - val labels: Chunk[String] = httpThroughput.map(_._1) - - // ---- layout constants (SVG user units) ---- - - val W: Double = 600.0 - val H: Double = 320.0 - val marginTop: Double = 20.0 - val marginBot: Double = 50.0 // room for x-axis labels - val marginL: Double = 60.0 // room for y-axis labels - val marginR: Double = 20.0 - val chartW: Double = W - marginL - marginR - val chartH: Double = H - marginTop - marginBot - - private def viewBox: Svg.ViewBox = Svg.ViewBox(0, 0, W, H) - - // ---- colors ---- - - private val palette: Chunk[Style.Color] = Chunk( - Style.Color.rgb(59, 130, 246), // blue - Style.Color.rgb(34, 197, 94), // green - Style.Color.rgb(249, 115, 22) // orange - ) - - /** A stable palette color for the bar at index `i`. */ - def barColor(i: Int): Style.Color = palette(i % palette.length) - - private val axisColor: Style.Color = Style.Color.rgb(120, 120, 120) - private val gridColor: Style.Color = Style.Color.rgb(225, 225, 225) - private val labelColor: Style.Color = Style.Color.rgb(40, 40, 40) - - // ---- pure chart helpers ---- - - /** Map a value in `[domainMin, domainMax]` to `[rangeMin, rangeMax]` linearly, clamped to the range. - * - * Returns `rangeMin` when the domain is degenerate (`domainMax == domainMin`). - */ - def linearScale(domainMin: Double, domainMax: Double, rangeMin: Double, rangeMax: Double)(v: Double): Double = - if domainMax == domainMin then rangeMin - else - val t = (v - domainMin) / (domainMax - domainMin) - val out = rangeMin + t * (rangeMax - rangeMin) - val lo = math.min(rangeMin, rangeMax) - val hi = math.max(rangeMin, rangeMax) - if out < lo then lo else if out > hi then hi else out - end linearScale - - /** Return `(xOffset, bandWidth)` for the `i`-th of `n` bands across `[0, totalWidth]` with the given padding ratio. */ - def bandScale(i: Int, n: Int, totalWidth: Double, padding: Double = 0.1): (Double, Double) = - if n <= 0 then (0.0, totalWidth) - else - val slot = totalWidth / n.toDouble - val bandWidth = totalWidth * (1.0 - padding) / n.toDouble - val xOffset = i.toDouble * slot + (slot - bandWidth) / 2.0 - (xOffset, bandWidth) - end bandScale - - /** Return at most `maxTicks` evenly-spaced tick values covering `[min, max]`, snapped to a nice step. - * - * Degenerate inputs (`min == max` or `maxTicks <= 1`) return `Chunk(min)`. Every returned tick lies in `[min, max]`. - */ - def niceTicks(min: Double, max: Double, maxTicks: Int = 5): Chunk[Double] = - if maxTicks <= 1 || min == max then Chunk(min) - else - val rawStep = (max - min) / (maxTicks - 1).toDouble - val magnitude = math.pow(10.0, math.floor(math.log10(rawStep))) - val residual = rawStep / magnitude - val niceUnit = - if residual <= 1.0 then 1.0 - else if residual <= 2.0 then 2.0 - else if residual <= 5.0 then 5.0 - else 10.0 - val step = niceUnit * magnitude - @scala.annotation.tailrec - def loop(i: Int, t: Double, acc: Chunk[Double]): Chunk[Double] = - if i >= maxTicks || t > max + step * 1.0e-9 then acc - else loop(i + 1, t + step, acc.append(t)) - loop(0, min, Chunk.empty) - end if - end niceTicks - - private def formatValue(v: Double): String = - if v == v.toLong.toDouble then v.toLong.toString else f"$v%.1f" - - private def maxOf(values: Chunk[Double]): Double = values.foldLeft(1.0)(math.max) - - /** A stable per-bar id stem for the enclosing ``. */ - def barCellId(i: Int): String = "bar-cell-" + i.toString - - /** Id of the bar `` at index `i`. */ - def barRectId(i: Int): String = "bar-rect-" + i.toString - - // ---- axes ---- - - /** Render a horizontal x-axis: a baseline line plus one centered category label per band. */ - def renderXAxis(labels: Chunk[String], xs: Chunk[Double], y: Double, width: Double)(using Frame): Svg.G = - val baseline = Svg.line - .x1(marginL).y1(y).x2(marginL + width).y2(y) - .stroke(Svg.Paint.Color(axisColor)).strokeWidth(1.0) - val texts = labels.zip(xs).map { (label, cx) => - Svg.text - .x(cx).y(y + 16) - .textAnchor(Svg.TextAnchor.Middle) - .fill(Svg.Paint.Color(labelColor)) - .fontSize(Svg.SvgLength.px(11.0))(label) - } - Svg.g(baseline +: texts*) - end renderXAxis - - /** Render a vertical y-axis: the axis line plus a tick mark, gridline, and value label per tick. */ - def renderYAxis(ticks: Chunk[Double], scale: Double => Double, chartWidth: Double, labelX: Double)(using - Frame - ): Svg.G = - val axisLine = Svg.line - .x1(marginL).y1(marginTop).x2(marginL).y2(marginTop + chartH) - .stroke(Svg.Paint.Color(axisColor)).strokeWidth(1.0) - val parts = ticks.flatMap { t => - val ty = scale(t) - Chunk( - Svg.line // gridline across the plot - .x1(marginL).y1(ty).x2(marginL + chartWidth).y2(ty) - .stroke(Svg.Paint.Color(gridColor)).strokeWidth(1.0), - Svg.line // tick mark - .x1(marginL - 4).y1(ty).x2(marginL).y2(ty) - .stroke(Svg.Paint.Color(axisColor)).strokeWidth(1.0), - Svg.text - .x(labelX).y(ty + 4) - .textAnchor(Svg.TextAnchor.End) - .fill(Svg.Paint.Color(labelColor)) - .fontSize(Svg.SvgLength.px(10.0))(formatValue(t)) - ) - } - Svg.g(axisLine +: parts*) - end renderYAxis - - // ---- bar chart ---- - - /** Render the bar chart for the given values and labels as a complete typed `Svg.Root`. - * - * Each bar is one `Svg.rect` placed via `bandScale` (x/width) and `linearScale` (height/y, baseline at the bottom), - * carrying a native `Svg.title` tooltip and two SMIL `Svg.animate` children that grow it from the baseline: one - * tweens `height` from 0, the other slides `y` from the baseline up to the top, so the bar grows upward on load. - * - * Values and labels are zipped into aligned pairs, so iterating pairs (rather than indexing labels by a value - * index) cannot read past either collection even if a caller passed mismatched lengths. - */ - def renderBarChart(values: Chunk[Double], labels: Chunk[String])(using Frame): Svg.Root = - val pairs = values.zip(labels) - val n = pairs.length - val maxVal = maxOf(pairs.map(_._1)) - val baseline = chartH + marginTop - val yScale = linearScale(0.0, maxVal, baseline, marginTop) - val ticks = niceTicks(0.0, maxVal, 5) - - val bars = pairs.zipWithIndex.map { case ((v, label), i) => - val (bx, bw) = bandScale(i, n, chartW) - val xPx = bx + marginL - val barTop = yScale(v) - val barH = baseline - barTop - val rect = Svg.rect - .id(barRectId(i)) - .x(xPx).y(barTop).width(bw).height(barH) - .fill(Svg.Paint.Color(barColor(i)))( - Svg.title(s"$label: ${formatValue(v)} ops/s"), - Svg.animate.attributeName("height").from(0.0).to(barH).dur("0.6s").begin("0s").repeatCount("1"), - Svg.animate.attributeName("y").from(baseline).to(barTop).dur("0.6s").begin("0s").repeatCount("1") - ) - val valueLabel = Svg.text - .x(xPx + bw / 2.0).y(barTop - 4) - .textAnchor(Svg.TextAnchor.Middle) - .fill(Svg.Paint.Color(labelColor)) - .fontSize(Svg.SvgLength.px(11.0))(formatValue(v)) - // The value sits on top of the rect and SVG hit-tests the topmost element, so the cell wraps both - // in a ; an id on the gives the whole cell a stable target. - Svg.g.id(barCellId(i))(rect, valueLabel) - } - - val barLabels = pairs.map(_._2) - val xs = (0 until n).map(i => bandScale(i, n, chartW)._1 + marginL + bandScale(i, n, chartW)._2 / 2.0) - val xAxis = renderXAxis(barLabels, Chunk.from(xs), baseline, chartW) - val yAxis = renderYAxis(ticks, yScale, chartW, marginL - 8) - - Svg.svg.width(W.toInt).height(H.toInt).viewBox(viewBox)( - (yAxis +: xAxis +: bars)* - ) - end renderBarChart - - // ---- value tween ---- - - private val tweenSteps = 24 - private val tweenStepMs = 16 - - private def easeInOutCubic(t: Double): Double = - if t < 0.5 then 4.0 * t * t * t - else 1.0 - math.pow(-2.0 * t + 2.0, 3.0) / 2.0 - - /** Tween `ref` element-wise from its current values to `to` over an eased interpolation, stepping via `Async.sleep` - * (never blocks a thread), then pin the exact target. Requires `to` to be the same length as the current values; - * the bar chart always tweens between same-length datasets. - */ - def tweenTo(ref: SignalRef[Chunk[Double]], to: Chunk[Double])(using Frame): Unit < Async = - for - from <- ref.get - n = math.min(from.length, to.length) - _ <- Loop(1) { step => - val t = step.toDouble / tweenSteps.toDouble - val e = easeInOutCubic(t) - val interp = Chunk.from((0 until n).map(i => from(i) + (to(i) - from(i)) * e)) - ref.set(interp).andThen { - if step >= tweenSteps then Loop.done - else Async.sleep(tweenStepMs.millis).andThen(Loop.continue(step + 1)) - } - } - _ <- ref.set(to) - yield () - - // ---- styles ---- - - private val accent = Color.rgb(99, 102, 241) - private val rule = Color.rgb(221, 221, 221) - - private val pageStyle = Style.column.padding(16.px).gap(10.px).fontFamily(_.SansSerif) - private val barStyle = Style.row.gap(8.px).align(_.center) - private val btnStyle = Style.padding(6.px, 12.px).bg(accent).color(_.white).border(0.px, accent).cursor(_.pointer) - private val hintStyle = Style.fontSize(12.px).color(_.gray) - private val titleStyle = Style.fontSize(16.px) - private val svgWrap = Style.border(1.px, rule).maxWidth(100.pct) - - // ---- app ---- - - private[demo] def app: UI < Async = - for - // `values` holds the currently displayed bar heights; `useAlt` flips between the two datasets. - values <- Signal.initRef(httpThroughput.map(_._2)) - useAlt <- Signal.initRef(false) - - refresh = - for - alt <- useAlt.get - to = if alt then httpThroughput.map(_._2) else httpThroughputAlt.map(_._2) - // background fiber: tween the values, then record which dataset is now shown. - _ <- Fiber.initUnscoped(tweenTo(values, to).andThen(useAlt.set(!alt))) - yield () - - region = values.render(vs => renderBarChart(vs, labels)) - yield UI.div.style(pageStyle)( - UI.div.style(barStyle)( - UI.span("kyo-ui SVG bar chart").style(titleStyle), - UI.button("Refresh data").id("refresh-btn").style(btnStyle).onClick(refresh), - UI.span("HTTP throughput by runtime (ops/s), representative figures.").style(hintStyle) - ), - UI.div.style(svgWrap)(region) - ) - - run { - val port = args.headMaybe.flatMap(s => Maybe.fromOption(s.toIntOption)).getOrElse(0) - for - handlers <- UI.runHandlers("/")(app) - server <- HttpServer.init(port, "localhost")(handlers*) - _ <- Console.printLine(s"BarChart running on http://localhost:${server.port}/") - _ <- server.await - yield () - end for - } -end BarChart diff --git a/kyo-ui/shared/src/test/scala/demo/Cart.scala b/kyo-ui/shared/src/test/scala/demo/CartDemo.scala similarity index 99% rename from kyo-ui/shared/src/test/scala/demo/Cart.scala rename to kyo-ui/shared/src/test/scala/demo/CartDemo.scala index 6584791f22..48a7f6af6b 100644 --- a/kyo-ui/shared/src/test/scala/demo/Cart.scala +++ b/kyo-ui/shared/src/test/scala/demo/CartDemo.scala @@ -15,7 +15,7 @@ import kyo.UI.* * Demonstrates: a single source-of-truth `SignalRef`, derived `Signal`s via `.map` (cart lines and total), keyed list rendering with * `foreachKeyed`, per-row +/- steppers, `when` empty-state, and equal-column layout via `flexGrow(1).flexBasis(0.px)`. */ -object Cart extends KyoApp: +object CartDemo extends KyoApp: case class Product(id: String, name: String, cents: Int) derives CanEqual @@ -101,4 +101,4 @@ object Cart extends KyoApp: yield () end for } -end Cart +end CartDemo diff --git a/kyo-ui/shared/src/test/scala/demo/ChartFeatureGalleryDemo.scala b/kyo-ui/shared/src/test/scala/demo/ChartFeatureGalleryDemo.scala new file mode 100644 index 0000000000..e4327c849e --- /dev/null +++ b/kyo-ui/shared/src/test/scala/demo/ChartFeatureGalleryDemo.scala @@ -0,0 +1,325 @@ +package demo + +import kyo.* +import kyo.Chart.* +import kyo.Style.* +import kyo.UI.* + +/** A light-theme gallery of the STATIC chart features. + * + * Each cell isolates one feature over a small, readable dataset so the visual point is obvious: + * + * 1. Sequential color scale: a numeric color encoding mapped to a low/high gradient via + * `.legend(_.colorScaleSequential(low, high))`. + * 2. Error bars: `Chart.errorBar(x, y, low, high, capWidth)` over a series with variance. + * 3. Text annotations: a bar chart plus `Chart.text(x, y, label, anchor)` labeling each value. + * 4. Stacked filled area: `Chart.area` with `stack = Chart.by(_.region)` and a categorical color, + * so the per-group fills are distinct bands. + * 5. Theme + named palette: `.theme(_.dark.palette(Chart.Palette.Okabe))`. + * 6. Accessibility: `.title(...)` (implies `role="img"`) and `.desc(...)` on a chart. + * 7. Grouped (dodged) bar with categorical colorScale: distinct per-region colors + legend, no stack. + * 8. Colored errorBar via colorScale: per-category whisker colors. + * 9. Colored text annotations via colorScale: per-region colored value labels. + * 10. X tick rotation + theme font: long category labels rotated -40 degrees + Georgia font. + * + * `ChartFeatureGallery.app` is the page value. + */ +object ChartFeatureGalleryDemo extends KyoApp: + + // ---- domain ---- + + enum Region derives CanEqual: + case NA, EU, APAC + + /** One measured value with a lo/hi confidence band and a heat magnitude for the gradient. */ + case class Reading(month: String, value: Double, lo: Double, hi: Double, heat: Double, region: Region) + + val readings: Chunk[Reading] = Chunk( + Reading("Jan", 120, 104, 136, 10, Region.NA), + Reading("Feb", 145, 132, 158, 30, Region.NA), + Reading("Mar", 132, 118, 146, 55, Region.NA), + Reading("Apr", 168, 150, 186, 80, Region.NA), + Reading("May", 154, 140, 168, 100, Region.NA) + ) + + case class StackRow(month: String, units: Double, region: Region) + + val stackData: Chunk[StackRow] = Chunk( + StackRow("Jan", 60, Region.NA), + StackRow("Jan", 40, Region.EU), + StackRow("Jan", 25, Region.APAC), + StackRow("Feb", 72, Region.NA), + StackRow("Feb", 48, Region.EU), + StackRow("Feb", 30, Region.APAC), + StackRow("Mar", 80, Region.NA), + StackRow("Mar", 55, Region.EU), + StackRow("Mar", 38, Region.APAC), + StackRow("Apr", 90, Region.NA), + StackRow("Apr", 60, Region.EU), + StackRow("Apr", 44, Region.APAC) + ) + + // ---- dataset for grouped bar + colorScale demos ---- + + /** Multi-month, multi-region sales data for grouped-bar and colored errorBar/text demos. */ + case class SaleRow(month: String, units: Double, lo: Double, hi: Double, region: Region) + + val saleData: Chunk[SaleRow] = Chunk( + SaleRow("Jan", 120, 108, 132, Region.NA), + SaleRow("Jan", 80, 72, 88, Region.EU), + SaleRow("Jan", 60, 52, 68, Region.APAC), + SaleRow("Feb", 140, 125, 155, Region.NA), + SaleRow("Feb", 95, 86, 104, Region.EU), + SaleRow("Feb", 72, 64, 80, Region.APAC) + ) + + /** One value per region for the colored-text-annotation demo. Region is the x category (one bar per + * band), so a text mark's per-band label centers over its bar. A text mark positions by its x encoding + * and does NOT dodge to follow a grouped bar, so labeling dodged sub-bars (region inside a shared + * month band) would land the labels at the band centre, not over each sub-bar. + */ + case class RegionVal(name: String, value: Double, region: Region) + + val regionVals: Chunk[RegionVal] = Chunk( + RegionVal("NA", 120, Region.NA), + RegionVal("EU", 80, Region.EU), + RegionVal("APAC", 60, Region.APAC) + ) + + // Long category labels for the tick-rotation demo + case class CatRow(category: String, value: Double) + + val rotateData: Chunk[CatRow] = Chunk( + CatRow("London", 310), + CatRow("Berlin", 245), + CatRow("Madrid", 198), + CatRow("Lisbon", 134), + CatRow("Vienna", 92) + ) + + // ---- feature charts ---- + + /** 1. Numeric color encoding + sequential gradient (cool to warm). + * + * Uses a `point` mark: its per-row fills are the concrete interpolated colors of the sequential + * scale (low at the data minimum, high at the maximum). One gradient legend, with min/mid/max value + * labels, keeps the single feature (sequential color) clear and uncluttered. + */ + val sequentialColor: Svg.Root < Sync = + Chart(readings)( + point(x = _.month, y = _.value, color = _.heat) + ) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .legend(_.colorScaleSequential(Style.Color.blue, Style.Color.red)) + .margins(_.top(30)) + .size(360, 260) + .lower + + /** 2. Error bars: low-to-high whisker with caps and a center marker. */ + val errorBars: Svg.Root < Sync = + Chart(readings)( + point(x = _.month, y = _.value), + errorBar(x = _.month, y = _.value, low = _.lo, high = _.hi, capWidth = 10.0) + ) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .size(360, 240) + .lower + + /** 3. Text annotations stamping each bar's value. */ + val textAnnotations: Svg.Root < Sync = + Chart(readings)( + bar(x = _.month, y = _.value), + text(x = _.month, y = _.value, label = r => r.value.toInt.toString, anchor = Chart.TextAnchor.Middle) + ) + .yScale(_.withNice(true)) + .yAxis(_.ticks(4)) + .size(360, 240) + .lower + + /** 4. Stacked filled area, one band per region (categorical color). */ + val stackedArea: Svg.Root < Sync = + Chart(stackData)( + area(x = _.month, y = _.units, color = _.region, stack = by(_.region)) + ) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .legend(_.top) + .size(360, 240) + .lower + + /** 5. Dark theme with the Okabe-Ito accessible palette. */ + val themedPalette: Svg.Root < Sync = + Chart(stackData)( + bar(x = _.month, y = _.units, color = _.region, stack = by(_.region)) + ) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .legend(_.top) + .theme(_.dark.palette(Chart.Palette.Okabe)) + .size(360, 240) + .lower + + /** 6. Accessibility: title (implies role="img") and desc. */ + val accessible: Svg.Root < Sync = + Chart(readings)( + line(x = _.month, y = _.value) + ) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .title("Monthly readings, North America") + .desc("Line chart of the NA monthly reading value from January through May.") + .ariaLabel("Monthly NA readings line chart") + .size(360, 240) + .lower + + /** 7. Grouped (dodged) bar with categorical colorScale: distinct per-region colors + legend. No stack. + * + * A grouped bar is `bar(x, y, color = _.region)` with NO `stack` argument. The `.legend` + * call attaches a `colorScale[Region](...)` so each region gets its own explicit color and a + * legend swatch. Contrast with cell 4 (stacked area) where the same data is stacked instead. + */ + val groupedColorScale: Svg.Root < Sync = + Chart(saleData)( + bar(x = _.month, y = _.units, color = _.region) + ) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .legend(_.top.colorScale[Region]( + Region.NA -> Style.Color.rgb(59, 130, 246), + Region.EU -> Style.Color.rgb(16, 185, 129), + Region.APAC -> Style.Color.rgb(245, 158, 11) + )) + .size(360, 240) + .lower + + /** 8. Colored errorBar via colorScale: per-category whisker colors. + * + * Each region's error whiskers are colored to match the region's categorical color, making it + * easy to distinguish overlapping confidence intervals when multiple groups share an x position. + */ + val coloredErrorBar: Svg.Root < Sync = + Chart(saleData)( + point(x = _.month, y = _.units, color = _.region), + errorBar(x = _.month, y = _.units, low = _.lo, high = _.hi, color = _.region, capWidth = 8.0) + ) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .legend(_.top.colorScale[Region]( + Region.NA -> Style.Color.rgb(59, 130, 246), + Region.EU -> Style.Color.rgb(16, 185, 129), + Region.APAC -> Style.Color.rgb(245, 158, 11) + )) + .size(360, 240) + .lower + + /** 9. Colored text annotations via colorScale: each bar's value label uses the region's color. + * + * Region is the x category (one bar per band), so the `text` mark's per-band label centers over its + * bar. The `text` carries `color = _.region`, so each value label is drawn in that region's colorScale + * color, matching the bar and the legend. Pure value annotation without the uniform grey of an + * uncolored `text` mark. + */ + val coloredText: Svg.Root < Sync = + Chart(regionVals)( + bar(x = _.name, y = _.value, color = _.region), + text(x = _.name, y = _.value, label = r => r.value.toInt.toString, color = _.region, anchor = Chart.TextAnchor.Middle) + ) + .yScale(_.withNice(true)) + .yAxis(_.ticks(4)) + .legend(_.top.colorScale[Region]( + Region.NA -> Style.Color.rgb(59, 130, 246), + Region.EU -> Style.Color.rgb(16, 185, 129), + Region.APAC -> Style.Color.rgb(245, 158, 11) + )) + .size(360, 240) + .lower + + /** 10. X tick rotation + theme font: category names rotated -40 degrees in a Georgia serif font. + * + * `.xAxis(_.rotateTicks(-40))` tilts the x-axis tick labels so they read clearly without crowding + * the axis. `.theme(_.font("Georgia"))` applies a serif font to the axis labels. The combination is + * the canonical solution for charts whose x domain is a set of named categories (regions, SKUs, etc.). + */ + val rotatedTicksAndFont: Svg.Root < Sync = + Chart(rotateData)( + bar(x = _.category, y = _.value) + ) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .xAxis(_.rotateTicks(-40)) + .theme(_.font("Georgia")) + .margins(_.bottom(74).left(56)) + .size(480, 300) + .lower + + // ---- styles ---- + + private val pageStyle = + Style.column + .padding(24.px) + .gap(24.px) + .bg(Style.Color.white) + .fontFamily(_.SansSerif) + + private val gridStyle = + Style.row + .flexWrap(_.wrap) + .gap(20.px) + + private val cellStyle = + Style.column + .gap(6.px) + .align(_.start) + + private val titleStyle = + Style.fontSize(13.px) + .fontWeight(_.bold) + .color(Style.Color.rgb(60, 60, 80)) + + private def chartCell(title: String, svg: Svg.Root): UI.Ast.Div = + UI.div.style(cellStyle)( + UI.h3(title).style(titleStyle), + svg + ) + + val app: UI < Sync = + for + svgSequentialColor <- sequentialColor + svgErrorBars <- errorBars + svgTextAnnotations <- textAnnotations + svgStackedArea <- stackedArea + svgThemedPalette <- themedPalette + svgAccessible <- accessible + svgGroupedColorScale <- groupedColorScale + svgColoredErrorBar <- coloredErrorBar + svgColoredText <- coloredText + svgRotatedTicksAndFont <- rotatedTicksAndFont + yield UI.div.style(pageStyle)( + UI.h2("kyo-ui Chart Feature Gallery").style(Style.fontSize(18.px).fontWeight(_.bold)), + UI.div.style(gridStyle)( + chartCell("1. Sequential color gradient", svgSequentialColor), + chartCell("2. Error bars (lo/hi whiskers)", svgErrorBars), + chartCell("3. Text value annotations", svgTextAnnotations), + chartCell("4. Stacked filled area", svgStackedArea), + chartCell("5. Dark theme + Okabe palette", svgThemedPalette), + chartCell("6. Accessibility (title + desc)", svgAccessible), + chartCell("7. Grouped bar + categorical colorScale", svgGroupedColorScale), + chartCell("8. Colored errorBar via colorScale", svgColoredErrorBar), + chartCell("9. Colored text annotations via colorScale", svgColoredText), + chartCell("10. X tick rotation + theme font (Georgia)", svgRotatedTicksAndFont) + ) + ) + + run { + val port = args.headMaybe.flatMap(s => Maybe.fromOption(s.toIntOption)).getOrElse(0) + for + handlers <- UI.runHandlers("/")(app) + server <- HttpServer.init(port, "localhost")(handlers*) + _ <- Console.printLine(s"ChartFeatureGallery running on http://localhost:${server.port}/") + _ <- server.await + yield () + end for + } +end ChartFeatureGalleryDemo diff --git a/kyo-ui/shared/src/test/scala/demo/ChartReactiveScalesDemo.scala b/kyo-ui/shared/src/test/scala/demo/ChartReactiveScalesDemo.scala new file mode 100644 index 0000000000..cb17298e9f --- /dev/null +++ b/kyo-ui/shared/src/test/scala/demo/ChartReactiveScalesDemo.scala @@ -0,0 +1,197 @@ +package demo + +import kyo.* +import kyo.Chart.* +import kyo.Style.* +import kyo.UI.* + +/** Shows the REACTIVE and SCALE features, captured across two frames so the morph is visible. + * + * The page holds three charts: + * + * 1. Reactive morph: a line chart bound to a `Signal[Chunk[Wave]]` with `.animate(_.ease(...))`. A background + * fiber replaces the data with a same-structure update once between the two captured frames, so the path + * tweens rather than snapping. + * 2. Clamp: a bar chart with a FIXED y-domain `.yScale(_.linear(lo, hi).withClamp(true))`. One bar's value + * sits above `hi`; with clamp on it pins to the top of the plot instead of overflowing the frame. + * 3. Scale readback overlay: a static bar chart lowered with `lowerWithScales`, then composed inside an outer + * `Svg.svg` together with a horizontal target `Svg.line` drawn at `scales.y.toPixel(target)`, so the rule + * lands on the exact data pixel for the target value. + * + * `ChartReactiveScales.app` builds the page (allocating the signal and starting the fiber). + */ +object ChartReactiveScalesDemo extends KyoApp: + + // ---- domain ---- + + /** One sample of a series; the morph swaps the whole chunk for a same-length, same-x chunk. */ + case class Wave(x: Double, y: Double) derives CanEqual + + /** Initial series captured in frame 1. */ + val waveStart: Seq[Wave] = Chunk( + Wave(0, 40), + Wave(1, 70), + Wave(2, 55), + Wave(3, 90), + Wave(4, 65), + Wave(5, 110), + Wave(6, 80) + ) + + /** Same structure (same length and x values), different y values: captured in frame 2 after the tick. */ + val waveEnd: Seq[Wave] = Chunk( + Wave(0, 95), + Wave(1, 50), + Wave(2, 120), + Wave(3, 60), + Wave(4, 135), + Wave(5, 75), + Wave(6, 150) + ) + + // ---- clamp demo data ---- + + case class Bar(label: String, value: Double) + + private val clampLo = 0.0 + private val clampHi = 100.0 + + /** The "Spike" bar at 160 exceeds the fixed domain max of 100; clamp pins it to the top. */ + val clampData: Chunk[Bar] = Chunk( + Bar("A", 30), + Bar("B", 65), + Bar("C", 90), + Bar("Spike", 160), + Bar("D", 45) + ) + + // ---- overlay demo data ---- + + case class Sample(name: String, score: Double) + + val overlayData: Chunk[Sample] = Chunk( + Sample("Q1", 42), + Sample("Q2", 78), + Sample("Q3", 61), + Sample("Q4", 95) + ) + + /** The data value we draw a target rule at; the overlay uses `scales.y.toPixel` to place it exactly. */ + private val targetScore = 70.0 + private val overlayW = 360 + private val overlayH = 240 + + // ---- clamp chart (static) ---- + + val clampChart: Svg.Root < Sync = + Chart(clampData)( + bar(x = _.label, y = _.value, color = _.label) + ) + .yScale(_.linear(clampLo, clampHi).withClamp(true)) + .yAxis(_.grid.ticks(5)) + .legend(_.hidden) + .size(360, 240) + .lower + + /** A bar chart plus a target rule placed at the exact pixel for `targetScore` via the read-back y-scale. */ + val overlayChart: Svg.Root < Sync = + for + (chartSvg, scales) <- + Chart(overlayData)( + bar(x = _.name, y = _.score) + ) + .yScale(_.linear(0.0, 100.0)) + .yAxis(_.grid.ticks(5)) + .size(overlayW, overlayH) + .lowerWithScales + yield + val targetPx = scales.y.toPixel(targetScore) + val target = + Svg.line + .x1(scales.plot.x) + .x2(scales.plot.x + scales.plot.width) + .y1(targetPx) + .y2(targetPx) + .stroke(Svg.Paint.Color(Style.Color.red)) + .strokeWidth(2.0) + .strokeDasharray(Seq(6.0, 4.0)) + Svg.svg + .width(overlayW) + .height(overlayH)( + chartSvg, + target + ) + + // ---- styles ---- + + private val pageStyle = + Style.column + .padding(24.px) + .gap(24.px) + .bg(Style.Color.white) + .fontFamily(_.SansSerif) + + private val gridStyle = + Style.row + .flexWrap(_.wrap) + .gap(20.px) + + private val cellStyle = + Style.column + .gap(6.px) + .align(_.start) + + private val titleStyle = + Style.fontSize(13.px) + .fontWeight(_.bold) + .color(Style.Color.rgb(60, 60, 80)) + + private def chartCell(title: String, svg: Svg.Root): UI.Ast.Div = + UI.div.style(cellStyle)( + UI.h3(title).style(titleStyle), + svg + ) + + /** Builds the page: allocates the live signal, starts the one-shot morph fiber, assembles the cells. */ + def app: UI < Async = + for + wave <- Signal.initRef(waveStart) + + // One-shot morph: after the first frame is captured, swap to the same-structure end data so the + // animated path tweens between the two screenshots. No var, no blocking; just a delayed set. + _ <- Fiber.initUnscoped { + Async.sleep(2500.millis).andThen(wave.set(waveEnd)) + } + + morphChart <- + Chart(wave)( + line(x = _.x, y = _.y, curve = Curve.monotone), + point(x = _.x, y = _.y) + ) + .yScale(_.linear(0.0, 200.0)) + .yAxis(_.grid.ticks(5)) + .animate(_.ease(800.millis)) + .size(360, 240) + .lower + svgClampChart <- clampChart + svgOverlayChart <- overlayChart + yield UI.div.style(pageStyle)( + UI.h2("kyo-ui Chart Reactive + Scales").style(Style.fontSize(18.px).fontWeight(_.bold)), + UI.div.style(gridStyle)( + chartCell("1. Reactive morph (eased, tweens between frames)", morphChart), + chartCell("2. Clamp (Spike=160 pinned to domain max 100)", svgClampChart), + chartCell("3. Scale readback overlay (target rule at y=70px)", svgOverlayChart) + ) + ) + + run { + val port = args.headMaybe.flatMap(s => Maybe.fromOption(s.toIntOption)).getOrElse(0) + for + handlers <- UI.runHandlers("/")(app) + server <- HttpServer.init(port, "localhost")(handlers*) + _ <- Console.printLine(s"ChartReactiveScales running on http://localhost:${server.port}/") + _ <- server.await + yield () + end for + } +end ChartReactiveScalesDemo diff --git a/kyo-ui/shared/src/test/scala/demo/ChartShowcaseDemo.scala b/kyo-ui/shared/src/test/scala/demo/ChartShowcaseDemo.scala new file mode 100644 index 0000000000..b47625aed9 --- /dev/null +++ b/kyo-ui/shared/src/test/scala/demo/ChartShowcaseDemo.scala @@ -0,0 +1,294 @@ +package demo + +import kyo.* +import kyo.Chart.* +import kyo.Style.* +import kyo.UI.* + +/** Animated + interactive showcase of chart features. + * + * Four panels demonstrate the API surface: + * + * 1. Animated dual-axis combo (`yScaleRight`): colored bars on the left axis and a growth-rate line on + * the independent right axis, both bound to a `Signal` and tweening via `.animate` when the one-shot + * morph fires. Highlights the dual-axis independent-scale feature. + * 2. Interactive highlight on a multi-series line chart: three categorical colored lines via + * `.legend(_.colorScale[Series](...))` + `.onSelect(ref)` + `.interaction(_.highlightSelect)`. + * A background fiber sets the ref to a chosen series after the first captured frame, so frame 2 shows + * the highlighted series with the dark select stroke. + * 3. Animated multi-series colored AREA: three non-stacked area series that morph between two + * same-structure datasets via `.animate`. Shows that animated-area colorScale colors persist + * through the morph. + * 4. Animated bars with per-bar opacity + text labels: `.animate` on bars with `opacity` and `label` + * encodings that update live (value annotations float above the bars). + * + * All four panels are driven by one "Animate & Highlight" button: clicking it swaps every chart's Signal + * to its end dataset (the `.animate` charts tween) and selects a real Beta row (the line chart highlights + * it). `ChartShowcase.app` builds the page. + */ +object ChartShowcaseDemo extends KyoApp: + + // ---- domain: panel 1 - dual-axis animated combo ---- + + case class Rev(month: String, revenue: Double, growth: Double) derives CanEqual + + val revStart: Seq[Rev] = Chunk( + Rev("Jan", 45_000, 0.0), + Rev("Feb", 52_000, 15.6), + Rev("Mar", 48_000, -7.7), + Rev("Apr", 61_000, 27.1), + Rev("May", 70_000, 14.8), + Rev("Jun", 83_000, 18.6) + ) + + val revEnd: Seq[Rev] = Chunk( + Rev("Jan", 55_000, 0.0), + Rev("Feb", 68_000, 23.6), + Rev("Mar", 74_000, 8.8), + Rev("Apr", 91_000, 22.9), + Rev("May", 105_000, 15.4), + Rev("Jun", 118_000, 12.4) + ) + + // ---- domain: panel 2 - interactive multi-series line ---- + + enum Series derives CanEqual: + case Alpha, Beta, Gamma + + case class LinePt(x: Int, y: Double, series: Series) derives CanEqual + + val lineData: Chunk[LinePt] = + val xs = Chunk.from(0 to 6) + xs.flatMap { i => + Chunk( + LinePt(i, 30 + i * 8.0 + math.sin(i) * 5, Series.Alpha), + LinePt(i, 60 + i * 3.5 - math.cos(i) * 8, Series.Beta), + LinePt(i, 45 + i * 6.0 + math.sin(i * 0.8) * 10, Series.Gamma) + ) + } + end lineData + + // ---- domain: panel 3 - animated multi-series area ---- + + case class AreaPt(x: Int, y: Double, grp: String) derives CanEqual + + val areaStart: Seq[AreaPt] = + Chunk.from((0 to 5).flatMap { i => + Chunk( + AreaPt(i, 20 + i * 6.0, "North"), + AreaPt(i, 35 + i * 4.0, "South"), + AreaPt(i, 15 + i * 3.0, "West") + ) + }) + + val areaEnd: Seq[AreaPt] = + Chunk.from((0 to 5).flatMap { i => + Chunk( + AreaPt(i, 40 + i * 5.0, "North"), + AreaPt(i, 25 + i * 7.0, "South"), + AreaPt(i, 30 + i * 4.5, "West") + ) + }) + + // ---- domain: panel 4 - animated bars with opacity + labels ---- + + case class BarItem(label: String, value: Double, opacity: Double) derives CanEqual + + val barsStart: Seq[BarItem] = Chunk( + BarItem("Q1", 42.0, 0.5), + BarItem("Q2", 78.0, 0.7), + BarItem("Q3", 61.0, 0.6), + BarItem("Q4", 95.0, 0.9), + BarItem("Q5", 53.0, 0.55) + ) + + val barsEnd: Seq[BarItem] = Chunk( + BarItem("Q1", 68.0, 0.9), + BarItem("Q2", 55.0, 0.6), + BarItem("Q3", 89.0, 0.85), + BarItem("Q4", 72.0, 0.7), + BarItem("Q5", 110.0, 1.0) + ) + + // ---- color palette ---- + + private val alphaColor = Style.Color.rgb(99, 102, 241) + private val betaColor = Style.Color.rgb(34, 197, 94) + private val gammaColor = Style.Color.rgb(245, 158, 11) + + private val northColor = Style.Color.rgb(56, 189, 248) + private val southColor = Style.Color.rgb(232, 121, 249) + private val westColor = Style.Color.rgb(52, 211, 153) + + // ---- styles ---- + + private val pageStyle = + Style.column + .padding(24.px) + .gap(24.px) + .bg(Style.Color.rgb(248, 250, 252)) + .fontFamily(_.SansSerif) + + private val gridStyle = + Style.row + .flexWrap(_.wrap) + .gap(20.px) + + private val cellStyle = + Style.column + .gap(8.px) + .align(_.start) + .padding(16.px) + .bg(Style.Color.white) + .rounded(10.px) + + private val titleStyle = + Style.fontSize(13.px) + .fontWeight(_.bold) + .color(Style.Color.rgb(30, 41, 59)) + + private val subtitleStyle = + Style.fontSize(11.px) + .color(Style.Color.rgb(100, 116, 139)) + + private val buttonStyle = + Style.fontSize(13.px) + .fontWeight(_.bold) + .color(Style.Color.white) + .bg(Style.Color.rgb(37, 99, 235)) + .padding(10.px) + .rounded(8.px) + + private def chartCell(title: String, subtitle: String, svg: Svg.Root): UI.Ast.Div = + UI.div.style(cellStyle)( + UI.div.style(Style.column.gap(2.px))( + UI.h3(title).style(titleStyle), + UI.div(subtitle).style(subtitleStyle) + ), + svg + ) + + // ---- app ---- + + /** Builds the showcase page: allocates signals, starts one-shot morph fibers, assembles panels. */ + def app: UI < Async = + for + revSignal <- Signal.initRef(revStart) + areaSignal <- Signal.initRef(areaStart) + barsSignal <- Signal.initRef(barsStart) + selected <- Signal.initRef(Maybe.empty[LinePt]) + + // One "Animate & Highlight" button drives every panel at once: it swaps each chart's Signal to + // its end dataset (so the .animate charts tween) and selects the Beta series (so the line chart + // highlights it with the dark select stroke). highlightSelect matches the selected value by + // equality against each series path's representative row, which lowerLine tags as the series' + // FIRST row, so we select Beta's first row (x = 0). Driving from one explicit click makes the + // before/after deterministic instead of racing a timer against page load. + betaRow = Maybe.fromOption(lineData.find(p => p.series == Series.Beta && p.x == 0)) + animateAction = + revSignal.set(revEnd) + .andThen(areaSignal.set(areaEnd)) + .andThen(barsSignal.set(barsEnd)) + .andThen(selected.set(betaRow)) + + // Panel 1: dual-axis animated combo + dualAxisChart <- + Chart(revSignal)( + bar(x = _.month, y = _.revenue, color = _.month), + line(x = _.month, y = _.growth, axis = Axis.Right, curve = Curve.monotone) + ) + .yScale(_.linear(0.0, 130_000.0)) + .yAxis(_.grid.ticks(5).label("Revenue ($)")) + .yScaleRight(_.linear(-20.0, 40.0)) + .yAxisRight(_.label("Growth (%)").grid) + .legend(_.hidden) + .animate(_.ease(800.millis)) + .size(620, 280) + .lower + + // Panel 2: interactive multi-series line with categorical colorScale + lineChart <- + Chart(lineData)( + line(x = _.x, y = _.y, color = _.series, curve = Curve.monotone) + ) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .legend(_.top.colorScale[Series]( + Series.Alpha -> alphaColor, + Series.Beta -> betaColor, + Series.Gamma -> gammaColor + )) + .onSelect(selected) + .interaction(_.highlightSelect) + .size(620, 280) + .lower + + // Panel 3: animated multi-series non-stacked area with colorScale + areaChart <- + Chart(areaSignal)( + area(x = _.x, y = _.y, color = _.grp) + ) + .yScale(_.linear(0.0, 70.0)) + .yAxis(_.grid.ticks(4)) + .legend(_.top.colorScale { + case "North" => northColor + case "South" => southColor + case _ => westColor + }) + .animate(_.ease(800.millis)) + .size(620, 280) + .lower + + // Panel 4: animated bars with per-bar opacity + text labels + barsChart <- + Chart(barsSignal)( + bar(x = _.label, y = _.value, opacity = _.opacity), + text(x = _.label, y = _.value, label = r => r.value.toInt.toString, anchor = Chart.TextAnchor.Middle) + ) + .yScale(_.linear(0.0, 120.0)) + .yAxis(_.grid.ticks(5)) + .animate(_.ease(800.millis)) + .size(620, 280) + .lower + yield UI.div.style(pageStyle)( + UI.div.style(Style.row.gap(16.px).align(_.center))( + UI.h2("kyo-ui Chart Showcase").style(Style.fontSize(20.px).fontWeight(_.bold).color(Style.Color.rgb(15, 23, 42))), + UI.button("Animate & Highlight").id("play").style(buttonStyle).onClick(animateAction) + ), + UI.div.style(gridStyle)( + chartCell( + "1. Dual-Axis Animated Combo", + "bars left + growth line right, independent yScaleRight, tweens on morph", + dualAxisChart + ), + chartCell( + "2. Interactive Multi-Series Line", + "3 colored lines, onSelect + highlightSelect; Animate selects Beta (dark stroke)", + lineChart + ) + ), + UI.div.style(gridStyle)( + chartCell( + "3. Animated Multi-Series Area", + "non-stacked colored areas with categorical colorScale, morphs between datasets", + areaChart + ), + chartCell( + "4. Animated Bars: opacity + text labels", + "per-bar opacity encoding + floating text annotations, live update", + barsChart + ) + ) + ) + + run { + val port = args.headMaybe.flatMap(s => Maybe.fromOption(s.toIntOption)).getOrElse(0) + for + handlers <- UI.runHandlers("/")(app) + server <- HttpServer.init(port, "localhost")(handlers*) + _ <- Console.printLine(s"ChartShowcase running on http://localhost:${server.port}/") + _ <- server.await + yield () + end for + } +end ChartShowcaseDemo diff --git a/kyo-ui/shared/src/test/scala/demo/Dashboard.scala b/kyo-ui/shared/src/test/scala/demo/DashboardDemo.scala similarity index 98% rename from kyo-ui/shared/src/test/scala/demo/Dashboard.scala rename to kyo-ui/shared/src/test/scala/demo/DashboardDemo.scala index f61af4d801..c9367572d9 100644 --- a/kyo-ui/shared/src/test/scala/demo/Dashboard.scala +++ b/kyo-ui/shared/src/test/scala/demo/DashboardDemo.scala @@ -17,7 +17,7 @@ import kyo.UI.Ast.HtmlContent * Demonstrates: shared server-side state, a `Fiber` background updater, fine-grained reactive text via `signal.render`, a reactive list * via `signal.foreach`, and equal-width cards via `flexGrow(1).flexBasis(0.px)`. */ -object Dashboard extends KyoApp: +object DashboardDemo extends KyoApp: private val pageStyle = Style.padding(24.px).fontFamily(FontFamily.SansSerif).gap(16.px) private val subtitleStyle = Style.color(Color.gray).fontSize(14.px) @@ -93,4 +93,4 @@ object Dashboard extends KyoApp: yield () end for } -end Dashboard +end DashboardDemo diff --git a/kyo-ui/shared/src/test/scala/demo/Drive.scala b/kyo-ui/shared/src/test/scala/demo/Drive.scala deleted file mode 100644 index 1cce463404..0000000000 --- a/kyo-ui/shared/src/test/scala/demo/Drive.scala +++ /dev/null @@ -1,68 +0,0 @@ -package demo - -import kyo.* - -/** Generic browser-driver used to validate demo interactivity end to end. - * - * Points a headless Chrome at a running demo server and runs a sequence of steps, so a click/fill that triggers a server-push diff can be - * verified by a follow-up assertion or screenshot. Launches at a desktop window size so screenshots reflect a real wide viewport. - * - * Usage: `sbt 'kyo-ui/Test/runMain demo.Drive ...'` where each step is `verb=arg` with `|`-separated sub-args: - * - `fill=#selector|text` type into an input - * - `click=#selector` click an element - * - `wait=400` sleep N milliseconds (let a server-push diff settle) - * - `assert=#selector|text` wait until the element's text contains `text` (fails loudly otherwise) - * - `shot=/path|width|height` write a PNG screenshot - */ -object Drive extends KyoApp: - - private def runSteps(steps: List[String])(using Frame): Unit < (Browser & Async & Abort[Throwable]) = - Kyo.foreachDiscard(steps) { step => - step.split("=", 2) match - case Array("fill", rest) => - val parts = rest.split('|') - Browser.fill(Browser.Selector.css(parts(0)), parts(1)) - case Array("type", rest) => - // Type character by character via real key events (fill sets the value in one shot, which - // would not exercise per-keystroke caret behaviour). - val parts = rest.split('|') - val sel = Browser.Selector.css(parts(0)) - val text = if parts.length > 1 then parts(1) else "" - Browser.click(sel).andThen(Kyo.foreachDiscard(text.toList)(ch => Browser.press(sel, Browser.Key(ch)))) - case Array("evalp", js) => - Browser.eval(js).map(r => Console.printLine(s"eval[$js] = $r")) - case Array("valeq", rest) => - val parts = rest.split('|') - Browser.value(Browser.Selector.css(parts(0))).map { v => - if v == parts(1) then Console.printLine(s"ok: ${parts(0)} value == '${parts(1)}'") - else Abort.fail(new RuntimeException(s"FAIL: ${parts(0)} value='$v' expected='${parts(1)}'")) - } - case Array("click", sel) => - Browser.click(Browser.Selector.css(sel)) - case Array("wait", ms) => - Async.sleep(ms.toInt.millis) - case Array("assert", rest) => - val parts = rest.split('|') - Browser.waitForText(Browser.Selector.css(parts(0)), (_: String).contains(parts(1))) - .andThen(Console.printLine(s"ok: ${parts(0)} contains '${parts(1)}'")) - case Array("shot", rest) => - val parts = rest.split('|') - val w = if parts.length > 1 then parts(1).toInt else 1100 - val h = if parts.length > 2 then parts(2).toInt else 800 - Browser.screenshot(w, h).map(_.writeFileBinary(parts(0))) - .andThen(Console.printLine(s"wrote ${parts(0)}")) - case _ => - Console.printLine(s"unknown step: $step") - } - - run { - val url = args(0) - val steps = args.drop(1).toList - for - base <- Browser.chromeForTestingLaunchConfig() - launch = base.copy(extraArgs = base.extraArgs ++ Chunk("--window-size=1440,1000", "--hide-scrollbars")) - _ <- Browser.run(launch)(Browser.goto(url).andThen(runSteps(steps))) - yield () - end for - } -end Drive diff --git a/kyo-ui/shared/src/test/scala/demo/Flamegraph.scala b/kyo-ui/shared/src/test/scala/demo/FlamegraphDemo.scala similarity index 99% rename from kyo-ui/shared/src/test/scala/demo/Flamegraph.scala rename to kyo-ui/shared/src/test/scala/demo/FlamegraphDemo.scala index efddb28ef0..e89919f6b0 100644 --- a/kyo-ui/shared/src/test/scala/demo/Flamegraph.scala +++ b/kyo-ui/shared/src/test/scala/demo/FlamegraphDemo.scala @@ -3,7 +3,6 @@ package demo import kyo.* import kyo.Style.* import kyo.UI.* -import scala.language.implicitConversions /** Interactive flamegraph built entirely on the kyo-ui SVG layer and served as a server-push app via `UI.runHandlers`. * @@ -20,7 +19,7 @@ import scala.language.implicitConversions * * Run via `sbt 'kyo-ui/Test/runMain demo.Flamegraph'` (optional port as the first argument). */ -object Flamegraph extends KyoApp: +object FlamegraphDemo extends KyoApp: // ---- data ---- @@ -363,4 +362,4 @@ object Flamegraph extends KyoApp: yield () end for } -end Flamegraph +end FlamegraphDemo diff --git a/kyo-ui/shared/src/test/scala/demo/HtmlSnapshot.scala b/kyo-ui/shared/src/test/scala/demo/HtmlSnapshotDemo.scala similarity index 96% rename from kyo-ui/shared/src/test/scala/demo/HtmlSnapshot.scala rename to kyo-ui/shared/src/test/scala/demo/HtmlSnapshotDemo.scala index bd430c9c5a..9e8ef8a495 100644 --- a/kyo-ui/shared/src/test/scala/demo/HtmlSnapshot.scala +++ b/kyo-ui/shared/src/test/scala/demo/HtmlSnapshotDemo.scala @@ -14,7 +14,7 @@ import kyo.UI.* * * Demonstrates: `UI.runRender` for SSR, and that one `UI` value renders to plain HTML with no DOM or browser. */ -object HtmlSnapshot extends KyoApp: +object HtmlSnapshotDemo extends KyoApp: case class Product(name: String, price: String) @@ -43,4 +43,4 @@ object HtmlSnapshot extends KyoApp: _ <- Console.printLine(html) yield () } -end HtmlSnapshot +end HtmlSnapshotDemo diff --git a/kyo-ui/shared/src/test/scala/demo/Kanban.scala b/kyo-ui/shared/src/test/scala/demo/KanbanDemo.scala similarity index 99% rename from kyo-ui/shared/src/test/scala/demo/Kanban.scala rename to kyo-ui/shared/src/test/scala/demo/KanbanDemo.scala index 0aa1280e9e..ed5bea9d1f 100644 --- a/kyo-ui/shared/src/test/scala/demo/Kanban.scala +++ b/kyo-ui/shared/src/test/scala/demo/KanbanDemo.scala @@ -18,7 +18,7 @@ import kyo.UI.Ast.HtmlContent * `Signal[Chunk[Card]]`s via `.map`, keyed list rendering with `foreachKeyed` (so moving one card never disturbs the others), a reactive * per-column count, `when` empty-state placeholders, two-way `value` binding on the new-card input, and flex-row/column layout via `Style`. */ -object Kanban extends KyoApp: +object KanbanDemo extends KyoApp: case class Card(id: String, title: String) derives CanEqual case class Board(todo: Chunk[Card], doing: Chunk[Card], done: Chunk[Card]) derives CanEqual @@ -140,4 +140,4 @@ object Kanban extends KyoApp: yield () end for } -end Kanban +end KanbanDemo diff --git a/kyo-ui/shared/src/test/scala/demo/LineChart.scala b/kyo-ui/shared/src/test/scala/demo/LineChart.scala deleted file mode 100644 index 052a0e1c1d..0000000000 --- a/kyo-ui/shared/src/test/scala/demo/LineChart.scala +++ /dev/null @@ -1,253 +0,0 @@ -package demo - -import kyo.* -import kyo.Style.* -import kyo.UI.* -import scala.language.implicitConversions - -/** Animated line chart built entirely on the kyo-ui SVG layer and served as a server-push app via `UI.runHandlers`. - * - * This is a reference for drawing a line/area chart with the SVG API. Scales, ticks, and axes are pure helpers - * (`linearScale`, `niceTicks`, `renderXAxis`, `renderYAxis`); every visual node is a typed `Svg.*` factory (`Svg.path`, - * `Svg.circle`, `Svg.line`, `Svg.text`, `Svg.animate`) with typed value DSLs (`ViewBox`, `Paint`, `PathData`, - * `SvgLength`). There is no raw markup and no string escape hatch. - * - * The animation is a draw-in: the line is one `Svg.path` whose `stroke-dasharray` is set to its own length and whose - * `stroke-dashoffset` starts at that same length (so nothing is visible), then a SMIL `Svg.animate` tweens the offset - * to zero, revealing the stroke from start to end. The browser drives the SMIL tween, so no server round-trips are - * needed. An area fill under the line and `Svg.circle` point markers complete the chart. - * - * Run via `sbt 'kyo-ui/Test/runMain demo.LineChart'` (optional port as the first argument). - */ -object LineChart extends KyoApp: - - // ---- data ---- - - /** Latency over iterations (ms per iteration). An illustrative series shaped to make the draw-in legible. */ - val latencySeries: Chunk[(String, Double)] = Chunk( - ("1", 8.2), - ("2", 7.5), - ("3", 9.1), - ("4", 6.8), - ("5", 7.2), - ("6", 8.9), - ("7", 7.0) - ) - - val labels: Chunk[String] = latencySeries.map(_._1) - val values: Chunk[Double] = latencySeries.map(_._2) - - // ---- layout constants (SVG user units) ---- - - val W: Double = 600.0 - val H: Double = 320.0 - val marginTop: Double = 20.0 - val marginBot: Double = 50.0 // room for x-axis labels - val marginL: Double = 60.0 // room for y-axis labels - val marginR: Double = 20.0 - val chartW: Double = W - marginL - marginR - val chartH: Double = H - marginTop - marginBot - - private def viewBox: Svg.ViewBox = Svg.ViewBox(0, 0, W, H) - - // ---- colors ---- - - private val lineColor: Style.Color = Style.Color.rgb(99, 102, 241) // indigo - private val axisColor: Style.Color = Style.Color.rgb(120, 120, 120) - private val gridColor: Style.Color = Style.Color.rgb(225, 225, 225) - private val labelColor: Style.Color = Style.Color.rgb(40, 40, 40) - private val white: Style.Color = Style.Color.rgb(255, 255, 255) - - // ---- pure chart helpers ---- - - /** Map a value in `[domainMin, domainMax]` to `[rangeMin, rangeMax]` linearly, clamped to the range. - * - * Returns `rangeMin` when the domain is degenerate (`domainMax == domainMin`). - */ - def linearScale(domainMin: Double, domainMax: Double, rangeMin: Double, rangeMax: Double)(v: Double): Double = - if domainMax == domainMin then rangeMin - else - val t = (v - domainMin) / (domainMax - domainMin) - val out = rangeMin + t * (rangeMax - rangeMin) - val lo = math.min(rangeMin, rangeMax) - val hi = math.max(rangeMin, rangeMax) - if out < lo then lo else if out > hi then hi else out - end linearScale - - /** Return at most `maxTicks` evenly-spaced tick values covering `[min, max]`, snapped to a nice step. - * - * Degenerate inputs (`min == max` or `maxTicks <= 1`) return `Chunk(min)`. Every returned tick lies in `[min, max]`. - */ - def niceTicks(min: Double, max: Double, maxTicks: Int = 5): Chunk[Double] = - if maxTicks <= 1 || min == max then Chunk(min) - else - val rawStep = (max - min) / (maxTicks - 1).toDouble - val magnitude = math.pow(10.0, math.floor(math.log10(rawStep))) - val residual = rawStep / magnitude - val niceUnit = - if residual <= 1.0 then 1.0 - else if residual <= 2.0 then 2.0 - else if residual <= 5.0 then 5.0 - else 10.0 - val step = niceUnit * magnitude - @scala.annotation.tailrec - def loop(i: Int, t: Double, acc: Chunk[Double]): Chunk[Double] = - if i >= maxTicks || t > max + step * 1.0e-9 then acc - else loop(i + 1, t + step, acc.append(t)) - loop(0, min, Chunk.empty) - end if - end niceTicks - - /** Sum of Euclidean distances between consecutive points: the exact polyline length, used to size the draw-in. */ - def pathLen(pts: Chunk[(Double, Double)]): Double = - @scala.annotation.tailrec - def go(i: Int, acc: Double): Double = - if i >= pts.length then acc - else - val dx = pts(i)._1 - pts(i - 1)._1 - val dy = pts(i)._2 - pts(i - 1)._2 - go(i + 1, acc + math.sqrt(dx * dx + dy * dy)) - go(1, 0.0) - end pathLen - - private def formatValue(v: Double): String = - if v == v.toLong.toDouble then v.toLong.toString else f"$v%.1f" - - private def maxOf(values: Chunk[Double]): Double = values.foldLeft(1.0)(math.max) - - // ---- axes ---- - - /** Render a horizontal x-axis: a baseline line plus one centered category label per point. */ - def renderXAxis(labels: Chunk[String], xs: Chunk[Double], y: Double, width: Double)(using Frame): Svg.G = - val baseline = Svg.line - .x1(marginL).y1(y).x2(marginL + width).y2(y) - .stroke(Svg.Paint.Color(axisColor)).strokeWidth(1.0) - val texts = labels.zip(xs).map { (label, cx) => - Svg.text - .x(cx).y(y + 16) - .textAnchor(Svg.TextAnchor.Middle) - .fill(Svg.Paint.Color(labelColor)) - .fontSize(Svg.SvgLength.px(11.0))(label) - } - Svg.g(baseline +: texts*) - end renderXAxis - - /** Render a vertical y-axis: the axis line plus a tick mark, gridline, and value label per tick. */ - def renderYAxis(ticks: Chunk[Double], scale: Double => Double, chartWidth: Double, labelX: Double)(using - Frame - ): Svg.G = - val axisLine = Svg.line - .x1(marginL).y1(marginTop).x2(marginL).y2(marginTop + chartH) - .stroke(Svg.Paint.Color(axisColor)).strokeWidth(1.0) - val parts = ticks.flatMap { t => - val ty = scale(t) - Chunk( - Svg.line // gridline across the plot - .x1(marginL).y1(ty).x2(marginL + chartWidth).y2(ty) - .stroke(Svg.Paint.Color(gridColor)).strokeWidth(1.0), - Svg.line // tick mark - .x1(marginL - 4).y1(ty).x2(marginL).y2(ty) - .stroke(Svg.Paint.Color(axisColor)).strokeWidth(1.0), - Svg.text - .x(labelX).y(ty + 4) - .textAnchor(Svg.TextAnchor.End) - .fill(Svg.Paint.Color(labelColor)) - .fontSize(Svg.SvgLength.px(10.0))(formatValue(t)) - ) - } - Svg.g(axisLine +: parts*) - end renderYAxis - - // ---- line chart ---- - - /** Render the line chart for the given values and labels as a complete typed `Svg.Root`. - * - * The line is one `Svg.path` built from a `PathData` (`moveTo` the first point, then `lineTo` through the rest), - * drawn in via a SMIL `Svg.animate` on `stroke-dashoffset` (from the full path length to zero). A translucent area - * `Svg.path` under the line and `Svg.circle` markers at each point complete the chart, with x/y axes and ticks. - * - * Values and labels are zipped into aligned pairs, so a mismatched-length input can never index past either one. - */ - def renderLineChart(values: Chunk[Double], labels: Chunk[String])(using Frame): Svg.Root = - val pairs = values.zip(labels) - val pLabels = pairs.map(_._2) - val pValues = pairs.map(_._1) - val maxVal = maxOf(pValues) - val baseline = chartH + marginTop - val yScale = linearScale(0.0, maxVal, baseline, marginTop) - val ticks = niceTicks(0.0, maxVal, 5) - val n = pairs.length - val xStep = chartW / math.max(n - 1, 1).toDouble - - val pts: Chunk[(Double, Double)] = pValues.zipWithIndex.map { (v, i) => - (marginL + i.toDouble * xStep, yScale(v)) - } - val total = pathLen(pts) - - val linePath: Svg.PathData = - val start = Svg.PathData.from(pts.head._1, pts.head._2) - pts.drop(1).foldLeft(start)((acc, p) => acc.lineTo(p._1, p._2)) - - val areaPath: Svg.PathData = - linePath.lineTo(pts.last._1, baseline).lineTo(pts.head._1, baseline).close - - val area = Svg.path - .d(areaPath) - .fill(Svg.Paint.Color(lineColor)).fillOpacity(0.15) - .stroke(Svg.Paint.None) - - val line = Svg.path - .d(linePath) - .stroke(Svg.Paint.Color(lineColor)).fill(Svg.Paint.None) - .strokeWidth(2.0) - .strokeDasharray(Seq(total, total)) - .strokeDashoffset(Svg.SvgLength.px(total))( - Svg.animate.attributeName("stroke-dashoffset").from(total).to(0.0).dur("1.2s").begin("0s").repeatCount("1") - ) - - val markers = pts.map { (px, py) => - Svg.circle.cx(px).cy(py).r(4) - .fill(Svg.Paint.Color(lineColor)) - .stroke(Svg.Paint.Color(white)).strokeWidth(2.0) - } - - val xAxis = renderXAxis(pLabels, Chunk.from(pts.map(_._1)), baseline, chartW) - val yAxis = renderYAxis(ticks, yScale, chartW, marginL - 8) - - Svg.svg.width(W.toInt).height(H.toInt).viewBox(viewBox)( - (yAxis +: xAxis +: area +: line +: markers)* - ) - end renderLineChart - - // ---- styles ---- - - private val rule = Color.rgb(221, 221, 221) - - private val pageStyle = Style.column.padding(16.px).gap(10.px).fontFamily(_.SansSerif) - private val barStyle = Style.row.gap(8.px).align(_.center) - private val hintStyle = Style.fontSize(12.px).color(_.gray) - private val titleStyle = Style.fontSize(16.px) - private val svgWrap = Style.border(1.px, rule).maxWidth(100.pct) - - // ---- app ---- - - private[demo] def app: UI < Async = - UI.div.style(pageStyle)( - UI.div.style(barStyle)( - UI.span("kyo-ui SVG line chart").style(titleStyle), - UI.span("latency over iterations (ms), illustrative series.").style(hintStyle) - ), - UI.div.style(svgWrap)(renderLineChart(values, labels)) - ) - - run { - val port = args.headMaybe.flatMap(s => Maybe.fromOption(s.toIntOption)).getOrElse(0) - for - handlers <- UI.runHandlers("/")(app) - server <- HttpServer.init(port, "localhost")(handlers*) - _ <- Console.printLine(s"LineChart running on http://localhost:${server.port}/") - _ <- server.await - yield () - end for - } -end LineChart diff --git a/kyo-ui/shared/src/test/scala/demo/LinkedSelectionDemo.scala b/kyo-ui/shared/src/test/scala/demo/LinkedSelectionDemo.scala new file mode 100644 index 0000000000..e4ac8514c1 --- /dev/null +++ b/kyo-ui/shared/src/test/scala/demo/LinkedSelectionDemo.scala @@ -0,0 +1,107 @@ +package demo + +import kyo.* +import kyo.Chart.* +import kyo.Style.* +import kyo.UI.* + +/** Linked views: clicking a bar in one chart drives a second chart, with no glue beyond a shared `SignalRef`. + * + * The left chart is a bar of category totals; the right chart is a detail line of the selected category's + * monthly series. The only wiring between them is one `Signal.initRef`: the bar chart's `.onSelect` writes + * the clicked datum into it, and the detail title and line read it back. There is no event bus and no + * callback plumbing; "interaction" is just another app signal. + * + * Run via `sbt 'kyo-ui/Test/runMain demo.LinkedSelection'` (optional port as the first argument). + */ +object LinkedSelectionDemo extends KyoApp: + + // ---- domain ---- + + /** A category and its headline total (one bar on the left chart). */ + case class Cat(name: String, total: Double) derives CanEqual + + /** One month of a category's series (one point on the right chart). */ + case class Pt(month: Int, value: Double) derives CanEqual + + private val cats: Chunk[Cat] = Chunk( + Cat("Widgets", 820.0), + Cat("Gadgets", 540.0), + Cat("Gizmos", 1180.0), + Cat("Doohickeys", 360.0), + Cat("Sprockets", 690.0) + ) + + /** A 12-point monthly series per category. Each series is a simple pure walk off the category total. */ + private val seriesByCat: Map[String, Chunk[Pt]] = + cats.map { c => + val series = Chunk.from((1 to 12).map(m => Pt(m, c.total / 12.0 * (1.0 + 0.4 * math.sin(m.toDouble))))) + c.name -> series + }.toMap + + private def seriesFor(name: String): Chunk[Pt] = seriesByCat.getOrElse(name, Chunk.empty) + + // ---- app ---- + + private[demo] def app: UI < Async = + for + // The single point of coupling between the two charts: the current selection as an app signal. + selected <- Signal.initRef(Maybe.empty[Cat]) + + // INTERACTION IS AN ORDINARY APP SIGNAL. + // `.onSelect(selected)` above is the only write; the title and detail line below are the only + // reads. Linking the two charts is nothing more than sharing this one `SignalRef`: no event bus, + // no callbacks, no chart-to-chart reference. The detail chart re-renders because it reads a signal + // that changed, exactly like any other reactive `UI`. + + // The detail data is derived from the same ref: empty until a bar is clicked, then that category's + // series. Because it is a `Signal`, the chart built over it updates itself; the marks region is + // reactive by construction, so no `.render` wrapper is needed here. + detailData = selected.map(sel => sel.fold(Seq.empty[Pt])(c => seriesFor(c.name))) + + // LEFT (write side): bar of totals. `.onSelect(selected)` publishes the clicked `Cat` into the ref. + totalsChart <- + Chart(cats)(bar(x = _.name, y = _.total)) + .onSelect(selected) + .interaction(_.highlightSelect) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .theme(_.dark) + .size(560, 300) + .lower + + detailChart <- + Chart(detailData)(line(x = _.month, y = _.value)) + .xScale(_.linear(1.0, 12.0)) + .yScale(_.linear(0.0, 150.0)) + .yAxis(_.grid.ticks(4)) + .theme(_.dark) + .size(560, 300) + .lower + yield + val totalsTitle = UI.h3("Total by product") + + // RIGHT (read side): a title and a detail line, both derived from the same `selected` ref. + val detailTitle = + selected.render(sel => UI.h3(sel.fold("Click a bar to drill in")(c => s"${c.name} over time"))) + + UI.div.style(Style.column.gap(16.px).padding(20.px).bg(Color.rgb(15, 18, 28)).color(Color.rgb(226, 232, 240)))( + UI.h2("Linked selection"), + UI.div.style(Style.row.gap(24.px).align(_.start))( + UI.div.style(Style.column.gap(8.px))(totalsTitle, UI.div(totalsChart)), + UI.div.style(Style.column.gap(8.px))(detailTitle, detailChart) + ) + ) + end app + + run { + val port = args.headMaybe.flatMap(s => Maybe.fromOption(s.toIntOption)).getOrElse(0) + for + handlers <- UI.runHandlers("/")(app) + server <- HttpServer.init(port, "localhost")(handlers*) + _ <- Console.printLine(s"LinkedSelection running on http://localhost:${server.port}/") + _ <- server.await + yield () + end for + } +end LinkedSelectionDemo diff --git a/kyo-ui/shared/src/test/scala/demo/LiveDashboardDemo.scala b/kyo-ui/shared/src/test/scala/demo/LiveDashboardDemo.scala new file mode 100644 index 0000000000..b84f585f41 --- /dev/null +++ b/kyo-ui/shared/src/test/scala/demo/LiveDashboardDemo.scala @@ -0,0 +1,386 @@ +package demo + +import kyo.* +import kyo.Chart.* +import kyo.Style.* +import kyo.UI.* + +/** A live service-metrics dashboard built on the `Chart` layer's reactive (`Signal`-backed) data sources. + * + * Four panels share state held in `SignalRef`s and driven by one background fiber that random-walks every metric on + * a fixed cadence. The dashboard demonstrates how the chart layer keeps a stable frame (axes, legend, plot box) while + * the marks region redraws on each emission: + * + * - KPI tiles: plain reactive `UI` (not charts) via `signalRef.render`, recoloring the big number by threshold. + * - Throughput: a bar chart over a `Signal[Chunk[Endpoint]]` with a FIXED y-domain so the axis never jumps and the + * bars animate to new heights. + * - Latency: one line mark split by a `series` color encoding (p50 / p99) over a FIXED-LENGTH rolling window + * (31 slots, t-30..t0); each tick shifts values left and appends the newest at t0, so the path structure is + * constant and the lines morph smoothly. The color encoding gives the chart a two-entry legend. + * - Status: a stacked bar (2xx/4xx/5xx per endpoint) grouped by status code, colored by monitoring convention + * (2xx green, 4xx amber, 5xx red) via a `colorScale`. + * - Error rate: a line over a rolling window of the overall error percentage, filling the bottom-right cell. + * + * The four panels sit in a fits-1200px two-column grid (symmetric ~20px outer margins, ~20px inner gutter); + * the KPI tiles span the full content width above them. + * + * The RNG is a pure linear-congruential seed threaded through a `Loop`; there is no shared mutable state and no thread + * is ever blocked (the cadence is `Async.sleep`). A pause control flips a `SignalRef[Boolean]`; while paused the engine + * idles on a short sleep and leaves every metric untouched. + * + * Run via `sbt 'kyo-ui/Test/runMain demo.LiveDashboard'` (optional port as the first argument). + */ +object LiveDashboardDemo extends KyoApp: + + // ---- domain types ---- + + /** Requests/second for one endpoint. */ + case class Endpoint(name: String, rps: Double) derives CanEqual + + /** One slot of the rolling latency window in long form: `t` is the relative index (-30..0), `series` is + * "p50" or "p99", and `ms` is the latency in milliseconds. Long form lets a single line mark split into + * one line per series via a `color` encoding, so the chart layer derives a two-entry legend. + */ + case class LatPoint(t: Int, series: String, ms: Double) derives CanEqual + + /** One slot of the rolling error-rate window: `t` is the relative index (-30..0), `pct` is the error + * percentage at that tick. + */ + case class ErrPoint(t: Int, pct: Double) derives CanEqual + + /** A status-code count for one endpoint; `code` is one of "2xx", "4xx", "5xx" and drives the stack grouping. */ + case class StatusRow(name: String, code: String, count: Double) derives CanEqual + + // ---- fixed layout / domain constants ---- + + private val endpointNames: Chunk[String] = Chunk("/login", "/feed", "/search", "/cart", "/checkout") + private val window: Int = 31 + + private val rpsMax: Double = 1000.0 // fixed throughput y-domain so the axis never jumps (tick-aligned: 0/500/1000) + private val latMax: Double = 400.0 // fixed latency y-domain (ms) + + // ---- pure RNG (linear congruential; seed threaded through the loop, no var) ---- + + /** Advances the LCG seed and returns `(nextSeed, uniformIn[0,1))`. Numerical recipes constants. */ + private def nextUnit(seed: Long): (Long, Double) = + val next = (seed * 6364136223846793005L + 1442695040888963407L) + // take the high 24 bits for a uniform in [0, 1) + val bits = ((next >>> 40) & 0xffffffL).toDouble + (next, bits / 0x1000000.toDouble) + end nextUnit + + /** A symmetric delta in `[-amp, amp]` from the seed; returns the advanced seed. */ + private def delta(seed: Long, amp: Double): (Long, Double) = + val (s, u) = nextUnit(seed) + (s, (u * 2.0 - 1.0) * amp) + + private def clamp(v: Double, lo: Double, hi: Double): Double = + if v < lo then lo else if v > hi then hi else v + + // ---- initial state ---- + + private val initThroughput: Chunk[Endpoint] = + Chunk( + Endpoint("/login", 420.0), + Endpoint("/feed", 880.0), + Endpoint("/search", 610.0), + Endpoint("/cart", 300.0), + Endpoint("/checkout", 180.0) + ) + + /** Latency window in long form: each of the 31 slots contributes one p50 row and one p99 row. */ + private val initLatency: Chunk[LatPoint] = + Chunk.from((0 until window).flatMap { i => + val t = i - (window - 1) // -30 .. 0 + Chunk(LatPoint(t, "p50", 60.0), LatPoint(t, "p99", 140.0)) + }) + + private val initErr: Chunk[ErrPoint] = + Chunk.from((0 until window).map { i => + val t = i - (window - 1) // -30 .. 0 + ErrPoint(t, 1.0) + }) + + private val initStatus: Chunk[StatusRow] = + Chunk.from(endpointNames.flatMap { name => + Chunk( + StatusRow(name, "2xx", 90.0), + StatusRow(name, "4xx", 8.0), + StatusRow(name, "5xx", 2.0) + ) + }) + + // ---- one engine step: random-walk every metric, threading the seed ---- + + /** Random-walk the throughput chunk; returns the advanced seed and the new chunk. */ + private def stepThroughput(seed: Long, cur: Chunk[Endpoint]): (Long, Chunk[Endpoint]) = + cur.foldLeft((seed, Chunk.empty[Endpoint])) { case ((s0, acc), e) => + val (s1, d) = delta(s0, 40.0) + (s1, acc.append(e.copy(rps = clamp(e.rps + d, 20.0, rpsMax)))) + } + + /** Shift the latency window left by one slot and append a freshly-walked newest p50/p99 pair at t0. + * + * Operates on the long-form chunk: the newest pair is derived from the current t0 rows, then every + * slot's two rows are re-indexed to -30..0 after dropping the oldest slot. + */ + private def stepLatency(seed: Long, cur: Chunk[LatPoint]): (Long, Chunk[LatPoint]) = + val lastP50 = cur.filter(_.series == "p50").lastMaybe.map(_.ms).getOrElse(60.0) + val lastP99 = cur.filter(_.series == "p99").lastMaybe.map(_.ms).getOrElse(140.0) + val (s1, d50) = delta(seed, 12.0) + val (s2, d99) = delta(s1, 28.0) + val newP50 = clamp(lastP50 + d50, 20.0, latMax * 0.6) + val newP99 = clamp(math.max(lastP99 + d99, newP50 + 20.0), newP50 + 20.0, latMax) + // Reconstruct the per-slot p50/p99 series, drop the oldest slot, append the newest, re-index to -30..0. + val p50s = cur.filter(_.series == "p50").map(_.ms).drop(1).append(newP50) + val p99s = cur.filter(_.series == "p99").map(_.ms).drop(1).append(newP99) + val rebuilt = Chunk.from((0 until window).flatMap { i => + val t = i - (window - 1) + Chunk(LatPoint(t, "p50", p50s(i)), LatPoint(t, "p99", p99s(i))) + }) + (s2, rebuilt) + end stepLatency + + /** Shift the error-rate window left by one slot and append the freshly-computed newest percentage at t0. */ + private def stepErr(cur: Chunk[ErrPoint], newPct: Double): Chunk[ErrPoint] = + val shifted = cur.map(_.pct).drop(1).append(newPct) + Chunk.from(shifted.zipWithIndex.map { case (p, i) => ErrPoint(i - (window - 1), p) }) + + /** Random-walk every status-code count for every endpoint. */ + private def stepStatus(seed: Long, cur: Chunk[StatusRow]): (Long, Chunk[StatusRow]) = + cur.foldLeft((seed, Chunk.empty[StatusRow])) { case ((s0, acc), row) => + val (amp, lo, hi) = row.code match + case "2xx" => (8.0, 40.0, 140.0) + case "4xx" => (2.0, 0.0, 30.0) + case _ => (1.5, 0.0, 20.0) + val (s1, d) = delta(s0, amp) + (s1, acc.append(row.copy(count = clamp(row.count + d, lo, hi)))) + } + + // ---- derived KPI helpers (pure) ---- + + private def totalRps(t: Chunk[Endpoint]): Double = t.foldLeft(0.0)(_ + _.rps) + + private def errorPct(s: Chunk[StatusRow]): Double = + val total = s.foldLeft(0.0)(_ + _.count) + val errors = s.filter(r => r.code == "4xx" || r.code == "5xx").foldLeft(0.0)(_ + _.count) + if total <= 0.0 then 0.0 else errors / total * 100.0 + end errorPct + + /** The most-recent (t0) p99 latency from the long-form window. */ + private def p99Now(l: Chunk[LatPoint]): Double = + l.filter(_.series == "p99").lastMaybe.map(_.ms).getOrElse(0.0) + + // ---- colors / styles (dark theme) ---- + + private val pageBg = Color.rgb(15, 18, 28) + private val panelBg = Color.rgb(24, 28, 42) + private val rule = Color.rgb(44, 50, 70) + private val textCol = Color.rgb(226, 232, 240) + private val mutedCol = Color.rgb(148, 163, 184) + private val green = Color.rgb(34, 197, 94) + private val amber = Color.rgb(245, 158, 11) + private val red = Color.rgb(239, 68, 68) + private val accent = Color.rgb(99, 102, 241) + + // Chart-layer Style.Color equivalents for colorScale mappings (the chart layer maps category -> Style.Color). + private val scGreen = Style.Color.rgb(34, 197, 94) + private val scAmber = Style.Color.rgb(245, 158, 11) + private val scRed = Style.Color.rgb(239, 68, 68) + private val scCyan = Style.Color.rgb(6, 182, 212) + + // Two-column grid sizing that fits a 1200px canvas: 20px page padding each side -> 1160 content; + // 20px inner gutter -> each column up to (1160 - 20) / 2 = 570; panels cap at 560 so a column plus + // gutter plus margins stays under 1200. Charts are 520 wide and fit inside a 560 panel's 12px padding. + private val panelMaxW: Int = 560 + private val chartW: Int = 520 + private val chartH: Int = 240 + + private val pageStyle = + Style.column.padding(20.px).gap(20.px).bg(pageBg).color(textCol).fontFamily(_.SansSerif).minWidth(100.pct) + + private val headerRow = Style.row.gap(12.px).align(_.center).justify(_.spaceBetween) + private val titleStyle = Style.fontSize(22.px).fontWeight(_.bold) + // Rows stretch across the full content width; each child grows equally and caps at panelMaxW. + private val kpiRow = Style.row.gap(20.px) + private val chartGrid = Style.row.gap(20.px) + + private val tileStyle = + Style.column.gap(6.px).padding(16.px).bg(panelBg).rounded(10.px).border(1.px, rule) + .flexGrow(1.0).flexBasis(0.px) + private val tileLabel = Style.fontSize(12.px).color(mutedCol) + private val bigNumber = Style.fontSize(34.px).fontWeight(_.bold) + private val panelStyle = + Style.column.gap(8.px).padding(12.px).bg(panelBg).rounded(10.px).border(1.px, rule) + .flexGrow(1.0).flexBasis(0.px).maxWidth(panelMaxW.px) + private val panelTitle = Style.fontSize(14.px).color(mutedCol) + + private val btnStyle = + Style.padding(8.px, 16.px).bg(accent).color(_.white).border(0.px, accent).rounded(8.px).cursor(_.pointer) + + /** Format a non-negative magnitude as an integer with thousands separators (e.g. 2438 -> "2,438"). + * + * Pure and allocation-light: groups the digit string from the right in threes via a fold, no var/while. + */ + private def withThousands(v: Double): String = + val digits = v.toLong.toString + val n = digits.length + digits.zipWithIndex + .map { case (c, i) => + val fromRight = n - i + if i > 0 && fromRight % 3 == 0 then s",$c" else c.toString + } + .mkString + end withThousands + + private def fmt0(v: Double): String = v.toLong.toString + private def fmt1(v: Double): String = f"$v%.1f" + + /** Label the rolling-window x-axis: the newest slot (t = 0) reads "now", older slots read their relative + * tick index (e.g. -20, -10). Used as the x-axis tick formatter for the latency and error-rate charts. + */ + private def timeAxisLabel(t: Double): String = + if math.abs(t) < 0.5 then "now" else t.toLong.toString + + // ---- KPI tile (reactive UI, not a chart) ---- + + private def tile(label: String, value: String, numberColor: Color)(using Frame): UI.Ast.Div = + UI.div.style(tileStyle)( + UI.div(label).style(tileLabel), + UI.div(value).style(bigNumber.color(numberColor)) + ) + + // ---- app ---- + + private[demo] def app: UI < Async = + for + throughput <- Signal.initRef[Seq[Endpoint]](initThroughput) + latency <- Signal.initRef[Seq[LatPoint]](initLatency) + status <- Signal.initRef[Seq[StatusRow]](initStatus) + errRate <- Signal.initRef[Seq[ErrPoint]](initErr) + paused <- Signal.initRef(false) + + // background engine: random-walk every metric on a fixed cadence, threading a pure seed (no var/while). + _ <- Fiber.initUnscoped { + Loop(0x9e3779b97f4a7c15L) { seed => + paused.get.map { isPaused => + if isPaused then Async.sleep(200.millis).andThen(Loop.continue(seed)) + else + for + t <- throughput.get + l <- latency.get + s <- status.get + e <- errRate.get + (s1, nt) = stepThroughput(seed, Chunk.from(t)) + (s2, nl) = stepLatency(s1, Chunk.from(l)) + (s3, ns) = stepStatus(s2, Chunk.from(s)) + ne = stepErr(Chunk.from(e), errorPct(ns)) + _ <- throughput.set(nt) + _ <- latency.set(nl) + _ <- status.set(ns) + _ <- errRate.set(ne) + _ <- Async.sleep(700.millis) + yield Loop.continue(s3) + } + } + } + + // KPI tiles: each reads one metric and recolors its number by threshold. + totalTile = throughput.render(t => tile("Total req/s", withThousands(totalRps(Chunk.from(t))), green)) + errorTile = status.render { s => + val pct = errorPct(Chunk.from(s)) + val color = if pct > 5.0 then red else if pct > 2.0 then amber else green + tile("Error %", fmt1(pct) + "%", color) + } + p99Tile = latency.render { l => + val v = p99Now(Chunk.from(l)) + val color = if v > 300.0 then red else if v > 200.0 then amber else green + tile("p99 latency", fmt0(v) + " ms", color) + } + + // A chart whose data source is a `Signal` is reactive by construction: the layer redraws only the + // marks region on each emission and keeps the frame (axes, legend) stable, so no `.render` wrapper + // is needed. (Wrapping would rebuild the whole chart per tick and reset the animation state.) + // Throughput bar chart: FIXED y-domain so the axis stays put; bars animate to new heights. + throughputChart <- Chart(throughput)(bar(x = _.name, y = _.rps, color = _.name)) + .yAxis(_.grid.ticks(4)) + .yScale(_.linear(0.0, rpsMax)) + .legend(_.hidden) + .theme(_.dark) + .animate(_.ease(400.millis)) + .size(chartW, chartH) + .lower + + // Latency lines over the fixed 31-slot rolling window: one line per series via the color encoding, + // so the layer derives a p50/p99 legend. Cyan p50 and amber p99 stay distinct from the bars. + latencyChart <- Chart(latency)(line(x = _.t, y = _.ms, color = _.series)) + .yAxis(_.grid.ticks(4)) + .xAxis(_.format(timeAxisLabel)) + .yScale(_.linear(0.0, latMax)) + .legend( + _.top.colorScale { + case "p50" => scCyan + case _ => scAmber + } + ) + .theme(_.dark) + .animate(_.ease(400.millis)) + .size(chartW, chartH) + .lower + + // Status stacked bar grouped by code, colored by monitoring convention: 2xx green, 4xx amber, 5xx red. + statusChart <- Chart(status)(bar(x = _.name, y = _.count, stack = by(_.code))) + .yScale(_.withNice(true)) + .yAxis(_.grid.ticks(4)) + .legend( + _.top.colorScale { + case "2xx" => scGreen + case "4xx" => scAmber + case _ => scRed + } + ) + .theme(_.dark) + .size(chartW, chartH) + .lower + + // Error-rate line over the rolling window: fills the bottom-right cell so all four quadrants balance. + errorChart <- Chart(errRate)(line(x = _.t, y = _.pct)) + .yAxis(_.grid.ticks(4)) + .xAxis(_.format(timeAxisLabel)) + .yScale(_.withNice(true)) + .theme(_.dark) + .animate(_.ease(400.millis)) + .size(chartW, chartH) + .lower + + // Pause / resume control; label reacts to the paused signal. + pauseBtn = paused.render { isPaused => + UI.button(if isPaused then "Resume" else "Pause").style(btnStyle).onClick(paused.updateAndGet(!_)) + } + yield UI.div.style(pageStyle)( + UI.div.style(headerRow)( + UI.div("Service Metrics").style(titleStyle), + pauseBtn + ), + UI.div.style(kpiRow)(totalTile, errorTile, p99Tile), + UI.div.style(chartGrid)( + UI.div.style(panelStyle)(UI.div("Throughput (req/s)").style(panelTitle), throughputChart), + UI.div.style(panelStyle)(UI.div("Latency p50 / p99 (ms)").style(panelTitle), latencyChart) + ), + UI.div.style(chartGrid)( + UI.div.style(panelStyle)(UI.div("Status codes by endpoint").style(panelTitle), statusChart), + UI.div.style(panelStyle)(UI.div("Error rate (%)").style(panelTitle), errorChart) + ) + ) + + run { + val port = args.headMaybe.flatMap(s => Maybe.fromOption(s.toIntOption)).getOrElse(0) + for + handlers <- UI.runHandlers("/")(app) + server <- HttpServer.init(port, "localhost")(handlers*) + _ <- Console.printLine(s"LiveDashboard running on http://localhost:${server.port}/") + _ <- server.await + yield () + end for + } +end LiveDashboardDemo diff --git a/kyo-ui/shared/src/test/scala/demo/Playground.scala b/kyo-ui/shared/src/test/scala/demo/PlaygroundDemo.scala similarity index 98% rename from kyo-ui/shared/src/test/scala/demo/Playground.scala rename to kyo-ui/shared/src/test/scala/demo/PlaygroundDemo.scala index e0b4da09dd..0fc2a8dd1c 100644 --- a/kyo-ui/shared/src/test/scala/demo/Playground.scala +++ b/kyo-ui/shared/src/test/scala/demo/PlaygroundDemo.scala @@ -14,7 +14,7 @@ import kyo.UI.* * Demonstrates: the `iframe` element with a reactive `src`, two-way `textarea` binding, derived signals via `.map`, and equal-column * layout via `flexGrow(1).flexBasis(0.px)`. */ -object Playground extends KyoApp: +object PlaygroundDemo extends KyoApp: private val sampleHtml = """ @@ -64,4 +64,4 @@ object Playground extends KyoApp: yield () end for } -end Playground +end PlaygroundDemo diff --git a/kyo-ui/shared/src/test/scala/demo/Router.scala b/kyo-ui/shared/src/test/scala/demo/RouterDemo.scala similarity index 98% rename from kyo-ui/shared/src/test/scala/demo/Router.scala rename to kyo-ui/shared/src/test/scala/demo/RouterDemo.scala index 912303201c..e044f92990 100644 --- a/kyo-ui/shared/src/test/scala/demo/Router.scala +++ b/kyo-ui/shared/src/test/scala/demo/RouterDemo.scala @@ -19,7 +19,7 @@ import kyo.UI.Ast.HtmlContent * Demonstrates: `route.render` view switching, a parameterized route, nav driven by writing a `SignalRef`, and active-link styling derived * from the route signal. */ -object Router extends KyoApp: +object RouterDemo extends KyoApp: private val users = Seq("1" -> "Ada Lovelace", "2" -> "Alan Turing", "3" -> "Grace Hopper") @@ -86,4 +86,4 @@ object Router extends KyoApp: yield () end for } -end Router +end RouterDemo diff --git a/kyo-ui/shared/src/test/scala/demo/Search.scala b/kyo-ui/shared/src/test/scala/demo/SearchDemo.scala similarity index 99% rename from kyo-ui/shared/src/test/scala/demo/Search.scala rename to kyo-ui/shared/src/test/scala/demo/SearchDemo.scala index 1f43954e2f..3f63907b88 100644 --- a/kyo-ui/shared/src/test/scala/demo/Search.scala +++ b/kyo-ui/shared/src/test/scala/demo/SearchDemo.scala @@ -17,7 +17,7 @@ import kyo.UI.* * Demonstrates: an `Async` event handler calling `HttpClient.getJson`, typed error recovery with `Abort.run`, two-way `value` binding, a * reactive results list via `signal.foreach`, and a tri-state (loading / error / results) driven by `signal.render`. */ -object Search extends KyoApp: +object SearchDemo extends KyoApp: // Wikipedia API response shape (only the fields we read). case class WikiResponse(query: WikiQuery) derives Schema @@ -106,4 +106,4 @@ object Search extends KyoApp: yield () end for } -end Search +end SearchDemo diff --git a/kyo-ui/shared/src/test/scala/demo/Shot.scala b/kyo-ui/shared/src/test/scala/demo/Shot.scala deleted file mode 100644 index ad18816252..0000000000 --- a/kyo-ui/shared/src/test/scala/demo/Shot.scala +++ /dev/null @@ -1,28 +0,0 @@ -package demo - -import kyo.* - -/** Generic screenshot tool used to visually validate the demos. - * - * Points a headless Chrome (via kyo-browser) at a running demo server, waits for the initial render to settle, and writes a PNG. - * - * Usage: `sbt 'kyo-ui/Test/runMain demo.Shot [width] [height] [settleMillis]'`. - */ -object Shot extends KyoApp: - run { - val url = args(0) - val out = args(1) - val width = if args.length > 2 then args(2).toInt else 1200 - val height = if args.length > 3 then args(3).toInt else 900 - val settle = if args.length > 4 then args(4).toInt else 800 - Browser.run { - for - _ <- Browser.goto(url) - _ <- Async.sleep(settle.millis) - img <- Browser.screenshot(width, height) - _ <- img.writeFileBinary(out) - _ <- Console.printLine(s"wrote $out (${width}x$height)") - yield () - } - } -end Shot diff --git a/kyo-ui/shared/src/test/scala/demo/Signup.scala b/kyo-ui/shared/src/test/scala/demo/SignupDemo.scala similarity index 99% rename from kyo-ui/shared/src/test/scala/demo/Signup.scala rename to kyo-ui/shared/src/test/scala/demo/SignupDemo.scala index 7800eada75..37a3cc5fc7 100644 --- a/kyo-ui/shared/src/test/scala/demo/Signup.scala +++ b/kyo-ui/shared/src/test/scala/demo/SignupDemo.scala @@ -17,7 +17,7 @@ import kyo.UI.* * `Signal.combineLatestAll`, `.disabled(Signal[Boolean])` submit gating, `when` for conditional inline errors and the post-submit view, and * `Form.onSubmit`. */ -object Signup extends KyoApp: +object SignupDemo extends KyoApp: private val pageStyle = Style.padding(24.px).fontFamily(FontFamily.SansSerif).maxWidth(420.px) private val fieldStyle = Style.column.gap(4.px).padding(8.px, 0.px) @@ -112,4 +112,4 @@ object Signup extends KyoApp: yield () end for } -end Signup +end SignupDemo diff --git a/kyo-ui/shared/src/test/scala/demo/SnakeDemo.scala b/kyo-ui/shared/src/test/scala/demo/SnakeDemo.scala new file mode 100644 index 0000000000..f7dec814f4 --- /dev/null +++ b/kyo-ui/shared/src/test/scala/demo/SnakeDemo.scala @@ -0,0 +1,222 @@ +package demo + +import kyo.* +import kyo.Style.* +import kyo.UI.* +import scala.annotation.tailrec + +/** The classic Snake game on the raw SVG layer: a grid of `Svg.rect`s with all state in one `SignalRef[Game]`. + * + * Three moving parts, with no glue between them beyond the shared ref: + * - A background `Fiber` runs a `Loop` that ticks on a fixed `Async.sleep` cadence, reads the game, advances + * it one step (move the head, eat and grow, detect wall/self collisions), and writes it back. It threads a + * pure linear-congruential seed (carried in the state) for food placement, so there is no `var`, no + * `while`, and no blocked thread. + * - `onKeyDown` on the focusable board maps the arrow keys / WASD to a queued turn and Space/Enter to a + * restart, by updating the same ref. A turn is rejected if it reverses the snake straight into its own neck. + * - The board and the status line `render` off the ref, so each tick redraws the grid reactively. + * + * Click the board once to focus it, then steer with the arrow keys (the snake waits for the first key before it + * starts moving). Run via `sbt 'kyo-ui/Test/runMain demo.Snake'` (optional port as the first argument). + */ +object SnakeDemo extends KyoApp: + + // ---- grid / domain ---- + + private val cols = 20 + private val rows = 20 + private val cell = 22 // px per cell + + /** A cell on the grid (column, row). */ + case class Pos(x: Int, y: Int) derives CanEqual + + /** A heading. `delta` is the per-step move; `opposite` is the illegal 180-degree reversal. */ + enum Dir derives CanEqual: + case Up, Down, Left, Right + def delta: Pos = this match + case Up => Pos(0, -1) + case Down => Pos(0, 1) + case Left => Pos(-1, 0) + case Right => Pos(1, 0) + def opposite: Dir = this match + case Up => Down + case Down => Up + case Left => Right + case Right => Left + end Dir + + /** The whole game. `snake` is head-first (index 0 is the head). `dir` is the direction last moved; `queued` + * is the next direction to apply, validated against `dir` so a fast double-tap cannot reverse into the neck. + * `running` stays false until the first turn, so the snake waits for input. `seed` is the LCG state used to + * place food. + */ + case class Game(snake: Chunk[Pos], dir: Dir, queued: Dir, food: Pos, seed: Long, running: Boolean, dead: Boolean) + derives CanEqual: + def score: Int = snake.size - initialLength + + /** Queue a turn from a key press: ignore reversals and dead state; the first accepted turn starts the run. */ + def turn(d: Dir): Game = + if dead || d == dir.opposite then this + else copy(queued = d, running = true) + end Game + + private val initialLength = 3 + + /** A length-3 snake at center heading right, with the first food placed off the given seed. */ + private def newGame(seed: Long): Game = + val cx = cols / 2 + val cy = rows / 2 + val body = Chunk(Pos(cx, cy), Pos(cx - 1, cy), Pos(cx - 2, cy)) + val (s1, food) = spawnFood(seed, body) + Game(body, Dir.Right, Dir.Right, food, s1, running = false, dead = false) + end newGame + + // ---- pure RNG (linear congruential) + food placement ---- + + /** Advances the LCG seed and returns `(nextSeed, uniformIn[0,1))`. */ + private def nextUnit(seed: Long): (Long, Double) = + val next = seed * 6364136223846793005L + 1442695040888963407L + val bits = ((next >>> 40) & 0xffffffL).toDouble + (next, bits / 0x1000000.toDouble) + end nextUnit + + /** Pick a random free cell for food, retrying until one is not under the snake; returns the advanced seed. */ + private def spawnFood(seed: Long, occupied: Chunk[Pos]): (Long, Pos) = + @tailrec def loop(s: Long): (Long, Pos) = + val (s1, ux) = nextUnit(s) + val (s2, uy) = nextUnit(s1) + val p = Pos((ux * cols).toInt, (uy * rows).toInt) + if occupied.contains(p) then loop(s2) else (s2, p) + end loop + loop(seed) + end spawnFood + + // ---- one engine step ---- + + /** Advance the snake one cell along its queued direction: move the head, eat-and-grow or shift the tail, and + * flag death on a wall or self collision. + */ + private def step(g: Game): Game = + val nd = g.queued + val nh = Pos(g.snake.head.x + nd.delta.x, g.snake.head.y + nd.delta.y) + if nh.x < 0 || nh.x >= cols || nh.y < 0 || nh.y >= rows then g.copy(dead = true) + else + val eats = nh == g.food + val grown = nh +: g.snake // prepend the new head + val body = if eats then grown else grown.dropRight(1) // drop the tail unless we grew + if body.tail.contains(nh) then g.copy(dead = true) // ran into our own body + else if eats then + val (s1, food1) = spawnFood(g.seed, body) + g.copy(snake = body, dir = nd, food = food1, seed = s1) + else g.copy(snake = body, dir = nd) + end if + end if + end step + + /** Map a typed key to a heading, accepting both the arrow keys and WASD. */ + private def dirOf(k: Keyboard): Maybe[Dir] = k match + case Keyboard.ArrowUp => Present(Dir.Up) + case Keyboard.ArrowDown => Present(Dir.Down) + case Keyboard.ArrowLeft => Present(Dir.Left) + case Keyboard.ArrowRight => Present(Dir.Right) + case Keyboard.Char(c) => + c.toLower match + case 'w' => Present(Dir.Up) + case 's' => Present(Dir.Down) + case 'a' => Present(Dir.Left) + case 'd' => Present(Dir.Right) + case _ => Absent + case _ => Absent + + // ---- colors / styles (dark theme) ---- + + private val pageBg = Style.Color.rgb(15, 18, 28) + private val boardBg = Style.Color.rgb(24, 28, 42) + private val boardEdge = Style.Color.rgb(44, 50, 70) + private val textCol = Style.Color.rgb(226, 232, 240) + private val mutedCol = Style.Color.rgb(148, 163, 184) + private val headCol = Style.Color.rgb(74, 222, 128) + private val bodyCol = Style.Color.rgb(34, 197, 94) + private val foodCol = Style.Color.rgb(239, 68, 68) + + private val boardPx = cols * cell + + private val pageStyle = Style.column.gap(12.px).padding(20.px).bg(pageBg).color(textCol).fontFamily(_.SansSerif) + private val statStyle = Style.fontSize(16.px).fontWeight(_.bold) + private val hintStyle = Style.fontSize(12.px).color(mutedCol) + + // ---- rendering: the whole board is one SVG, redrawn each tick ---- + + /** Render the board: a backing rect, the food as a circle, and one square per snake segment (head brighter). */ + private def board(g: Game)(using Frame): Svg.Root = + val backing = + Svg.rect.x(0).y(0).width(boardPx).height(boardPx) + .fill(Svg.Paint.Color(boardBg)) + .stroke(Svg.Paint.Color(boardEdge)).strokeWidth(1.0) + val food = + Svg.circle.cx(g.food.x * cell + cell / 2.0).cy(g.food.y * cell + cell / 2.0).r(cell / 2.0 - 3.0) + .fill(Svg.Paint.Color(foodCol)) + val segments = + g.snake.zipWithIndex.map { case (p, i) => + Svg.rect.x(p.x * cell + 1).y(p.y * cell + 1).width(cell - 2).height(cell - 2) + .fill(Svg.Paint.Color(if i == 0 then headCol else bodyCol)) + } + Svg.svg.width(boardPx).height(boardPx).viewBox(Svg.ViewBox(0, 0, boardPx, boardPx))( + (backing +: food +: segments)* + ) + end board + + // ---- app ---- + + private[demo] def app: UI < Async = + for + state <- Signal.initRef(newGame(0x2545f4914f6cdd1dL)) + + // Background engine: advance one step per tick once the game is running and alive. + _ <- Fiber.initUnscoped { + Loop(()) { _ => + for + g <- state.get + _ <- Kyo.when(g.running && !g.dead)(state.set(step(g))) + _ <- Async.sleep(120.millis) + yield Loop.continue(()) + } + } + yield + // Arrow keys / WASD queue a turn; Space or Enter restarts. All three just update the shared ref. + val onKey: KeyboardEvent => (Any < Async) = e => + if e.key == Keyboard.Space || e.key == Keyboard.Enter then state.updateAndGet(g => newGame(g.seed)) + else + dirOf(e.key) match + case Present(d) => state.updateAndGet(_.turn(d)) + case Absent => () + + val status = + state.render { g => + val msg = + if g.dead then s"Game over. Score ${g.score}. Press Space or Enter to restart." + else if !g.running then "Press an arrow key or WASD to start." + else s"Score ${g.score}" + UI.div(msg).style(statStyle) + } + + // `tabIndex(0)` makes the container focusable so it receives key events once clicked. + UI.div.style(pageStyle).tabIndex(0).onKeyDown(onKey)( + UI.h2("Snake"), + state.render(g => UI.div(board(g))), + status, + UI.div("Click the board, then steer with the arrow keys or WASD. Space or Enter restarts.").style(hintStyle) + ) + end app + + run { + val port = args.headMaybe.flatMap(s => Maybe.fromOption(s.toIntOption)).getOrElse(0) + for + handlers <- UI.runHandlers("/")(app) + server <- HttpServer.init(port, "localhost")(handlers*) + _ <- Console.printLine(s"Snake running on http://localhost:${server.port}/") + _ <- server.await + yield () + end for + } +end SnakeDemo diff --git a/kyo-ui/shared/src/test/scala/kyo/AnchorTest.scala b/kyo-ui/shared/src/test/scala/kyo/AnchorTest.scala index ad9bc38d11..d38f5a6f02 100644 --- a/kyo-ui/shared/src/test/scala/kyo/AnchorTest.scala +++ b/kyo-ui/shared/src/test/scala/kyo/AnchorTest.scala @@ -4,7 +4,6 @@ import kyo.Browser.* import kyo.Length.* import kyo.UI.* import kyo.UI.Ast.* -import scala.language.implicitConversions class AnchorTest extends UITest: diff --git a/kyo-ui/shared/src/test/scala/kyo/AppIntegrationItTest.scala b/kyo-ui/shared/src/test/scala/kyo/AppIntegrationItTest.scala index 2a6431e9ac..e05f24ad29 100644 --- a/kyo-ui/shared/src/test/scala/kyo/AppIntegrationItTest.scala +++ b/kyo-ui/shared/src/test/scala/kyo/AppIntegrationItTest.scala @@ -2,7 +2,6 @@ package kyo import kyo.Browser.* import kyo.UI.foreach -import scala.language.implicitConversions class AppIntegrationItTest extends UITest: diff --git a/kyo-ui/shared/src/test/scala/kyo/AriaTest.scala b/kyo-ui/shared/src/test/scala/kyo/AriaTest.scala index b21ddbfc2f..389f17cc62 100644 --- a/kyo-ui/shared/src/test/scala/kyo/AriaTest.scala +++ b/kyo-ui/shared/src/test/scala/kyo/AriaTest.scala @@ -3,7 +3,6 @@ package kyo import kyo.UI.* import kyo.UI.Ast.* import kyo.internal.HtmlRenderer -import scala.language.implicitConversions class AriaTest extends kyo.test.Test[Any]: @@ -88,4 +87,30 @@ class AriaTest extends kyo.test.Test[Any]: } } + // ---- role: the bare `role` HTML attribute, distinct from aria-role ---- + + "role(v) renders the bare role attribute on div" in { + val html = renderHtml(UI.div.role("button")) + html.map { s => + assert(s.contains("""role="button"""")) + // It must be the bare `role`, not `aria-role`. + assert(!s.contains("""aria-role="button"""")) + } + } + + "no role set emits no role attribute" in { + val html = renderHtml(UI.div("x")) + html.map { s => + assert(!s.contains("role="), s"Expected no role attribute but got: $s") + } + } + + "role and aria-role are distinct attributes and both render" in { + val html = renderHtml(UI.div.role("img").aria("role", "button")) + html.map { s => + assert(s.contains("""role="img""""), s"missing bare role: $s") + assert(s.contains("""aria-role="button""""), s"missing aria-role: $s") + } + } + end AriaTest diff --git a/kyo-ui/shared/src/test/scala/kyo/AttrsTest.scala b/kyo-ui/shared/src/test/scala/kyo/AttrsTest.scala index 29b3a6e153..ae5413efae 100644 --- a/kyo-ui/shared/src/test/scala/kyo/AttrsTest.scala +++ b/kyo-ui/shared/src/test/scala/kyo/AttrsTest.scala @@ -4,7 +4,6 @@ import kyo.Browser.* import kyo.Length.* import kyo.UI.* import kyo.UI.Ast.* -import scala.language.implicitConversions class AttrsTest extends UITest: diff --git a/kyo-ui/shared/src/test/scala/kyo/BoundTest.scala b/kyo-ui/shared/src/test/scala/kyo/BoundTest.scala index 8444b5c000..c4a4a8d7f5 100644 --- a/kyo-ui/shared/src/test/scala/kyo/BoundTest.scala +++ b/kyo-ui/shared/src/test/scala/kyo/BoundTest.scala @@ -1,7 +1,6 @@ package kyo import kyo.Browser.* -import scala.language.implicitConversions class BoundTest extends UITest: diff --git a/kyo-ui/shared/src/test/scala/kyo/ButtonTest.scala b/kyo-ui/shared/src/test/scala/kyo/ButtonTest.scala index 50fa39294e..bfcfe4e867 100644 --- a/kyo-ui/shared/src/test/scala/kyo/ButtonTest.scala +++ b/kyo-ui/shared/src/test/scala/kyo/ButtonTest.scala @@ -1,7 +1,6 @@ package kyo import kyo.Browser.* -import scala.language.implicitConversions class ButtonTest extends UITest: diff --git a/kyo-ui/shared/src/test/scala/kyo/ChartAxisTest.scala b/kyo-ui/shared/src/test/scala/kyo/ChartAxisTest.scala new file mode 100644 index 0000000000..0564b4a022 --- /dev/null +++ b/kyo-ui/shared/src/test/scala/kyo/ChartAxisTest.scala @@ -0,0 +1,1674 @@ +package kyo + +import kyo.Chart.* +import kyo.Svg.Coord +import kyo.Svg.PathCommand +import kyo.Svg.PathData +import kyo.UI.* +import kyo.UI.Ast.* +import kyo.internal.Scale +import scala.language.implicitConversions + +/** Tests for axes, legends, scale overrides, theme, two axes, and stacking. + * + * All concrete asserts use exact pixel values derived from the documented margin constants + * and scale mathematics. Layout defaults: plotX=60, plotY=20, plotW=560, plotH=420, + * baseline=440 (default 640x480, MarginL=60, MarginR=20, MarginT=20, MarginB=40). + * Two-axis layout: MarginR=60, so plotW=520. + */ +class ChartAxisTest extends kyo.test.Test[Any]: + + // ---- shared domain types ---- + + enum Region derives CanEqual, Plottable: + case NA, EU, APAC + + opaque type Usd <: Double = Double + object Usd: + def apply(d: Double): Usd = d + given Plottable[Usd] = Plottable.numeric + given CanEqual[Usd, Usd] = CanEqual.derived + implicit def doubleToUsd(d: Double): Usd = d + implicit def intToUsd(d: Int): Usd = d.toDouble + end Usd + + case class Sale(month: String, revenue: Usd, region: Region = Region.NA) + given CanEqual[Sale, Sale] = CanEqual.derived + + case class Row2Ax(month: String, revenue: Usd, growthPct: Double) + given CanEqual[Row2Ax, Row2Ax] = CanEqual.derived + + // ---- layout constants (must match ChartLower) ---- + private val PlotX = 60.0 + private val PlotY = 20.0 + private val PlotW = 560.0 // default (no right axis) + private val PlotH = 420.0 + private val Baseline = PlotY + PlotH // 440.0 + private val PlotWTwoAx = 520.0 // with right axis (MarginR=60) + private val Tol = 1.0e-6 + + private def assertClose(actual: Double, expected: Double, msg: String = "")(using Frame, kyo.test.AssertScope): Unit = + assert(math.abs(actual - expected) < Tol, s"$msg: expected $expected but got $actual") + + // ---- SVG tree navigation helpers ---- + + /** All `Svg.Line`s directly inside `root.children` (frame chrome). */ + private def frameLinesIn(root: Svg.Root): Chunk[Svg.Line] = + root.children.flatMap: + case l: Svg.Line => Chunk(l) + case _ => Chunk.empty + + /** All `Svg.Text`s directly inside `root.children` (frame chrome). */ + private def frameTextsIn(root: Svg.Root): Chunk[Svg.Text] = + root.children.flatMap: + case t: Svg.Text => Chunk(t) + case _ => Chunk.empty + + /** All `Svg.Rect`s directly inside `root.children` (frame chrome: background + legend swatches). */ + private def frameRectsIn(root: Svg.Root): Chunk[Svg.Rect] = + root.children.flatMap: + case r: Svg.Rect => Chunk(r) + case _ => Chunk.empty + + /** Extract the double value from a `Maybe[Coord]`. */ + private def numOf(c: Maybe[Coord])(using Frame, kyo.test.AssertScope): Double = c match + case Present(Coord.Num(v)) => v + case other => fail(s"Expected Coord.Num but got $other") + + /** Extract the `Style.Color` from a `Svg.Paint.Color`. */ + private def colorOf(fill: Maybe[Svg.Paint])(using Frame, kyo.test.AssertScope): Style.Color = fill match + case Present(Svg.Paint.Color(c)) => c + case other => fail(s"Expected Paint.Color but got $other") + + // ---- Test 1: yAxis(_.grid.ticks(3)) -> 3 gridlines + 3 tick labels ---- + + "yAxis(_.grid.ticks(3)) produces 3 gridline Lines and 3 tick Text labels at niceTick pixels" in { + // Data: two bars y=[1000, 2000]; yExtent: Continuous(0, 2000) after ensureZero + // niceTicks(0, 2000, 3): step=1000 -> ticks=[0, 1000, 2000] + // Scale.Linear(0, 2000, 440, 20): + // apply(0)=440, apply(1000)=230, apply(2000)=20 + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .yAxis(_.grid.ticks(3)) + (spec).lower.map { root => + // Gridlines are Svg.Line elements spanning the full plot width with strokeOpacity=0.3 + // (distinct from the axis lines which have no strokeOpacity set) + val allLines = frameLinesIn(root) + val gridLines = allLines.filter: l => + l.svgAttrs.x1.exists(_ == PlotX) && l.svgAttrs.x2.exists(_ == PlotX + PlotW) && + l.svgAttrs.y1 == l.svgAttrs.y2 && l.svgAttrs.strokeOpacity.isDefined + assert(gridLines.size == 3, s"Expected 3 gridlines but got ${gridLines.size}") + + // Tick labels are Svg.Text elements with TextAnchor.End (left y-axis) + val allTexts = frameTextsIn(root) + val tickLabels = allTexts.filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End) + assert(tickLabels.size == 3, s"Expected 3 tick labels but got ${tickLabels.size}") + + // Pixel positions: apply(0)=440, apply(1000)=230, apply(2000)=20 + val expectedYPx = Chunk(440.0, 230.0, 20.0) + val gridYs = gridLines.map(l => l.svgAttrs.y1.getOrElse(0.0)).toSeq.sorted + val expectedSorted = expectedYPx.toSeq.sorted + gridYs.zip(expectedSorted).foldLeft(()): (_, pair) => + assertClose(pair._1, pair._2, "gridline y pixel") + } + } + + // ---- Test 2: yScale(_.linear(0,5000)) fixes domain ---- + + "yScale(_.linear(0,5000)) fixes the domain: row at 2500 maps to the plot midpoint" in { + // Scale.Linear(0, 5000, 440, 20): + // apply(2500) = 440 + (2500/5000)*(20-440) = 440 - 210 = 230.0 + // Plot midpoint = (plotY + baseline)/2 = (20+440)/2 = 230.0 + val rows = Chunk(Sale("Jan", Usd(2500)), Sale("Feb", Usd(9999))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .yScale(_.linear(0.0, 5000.0)) + (spec).lower.map { root => + // Extract the bar rects from the marks G (last child of root) + val marksG: Svg.G = root.children.last match + case g: Svg.G => g + case other => fail(s"Expected marks G but got $other") + val rects = marksG.children.flatMap: + case r: Svg.Rect => Chunk(r) + case _ => Chunk.empty + assert(rects.size == 2, s"Expected 2 bar rects but got ${rects.size}") + + // Row at 2500: barY = 230.0, barH = baseline - barY = 440 - 230 = 210 + val r0 = rects(0) // "Jan" row at 2500 + assertClose(numOf(r0.svgAttrs.y), 230.0, "barY for 2500") + assertClose(numOf(r0.svgAttrs.height), 210.0, "barH for 2500") + + // Assert midpoint position regardless of data max (9999 would push scale if not fixed) + val plotMidpoint = (PlotY + Baseline) / 2.0 + assertClose(numOf(r0.svgAttrs.y), plotMidpoint, "barY at plot midpoint") + } + } + + // ---- Test 3: xScale(_.band) over Int year treats as categorical ---- + + "xScale(_.band) over an Int year produces a Band scale, not Linear" in { + // Data: rows with year Int in [2020, 2021, 2022] + // Without override: Plottable[Int].kind == Linear -> Linear scale + // With .xScale(_.band): override forces Band + // fitBand(Continuous(2020,2022)): lo=2020, hi=2022 -> keys=["2020","2021","2022"] + // n=3, slot=560/3, bandW=560*0.9/3=168 + case class YearRow(year: Int, value: Double) + val rows = Chunk(YearRow(2020, 100.0), YearRow(2021, 200.0), YearRow(2022, 300.0)) + val spec = Chart(rows)(bar(x = _.year, y = _.value)) + .xScale(_.band) + (spec).lower.map { root => + val marksG: Svg.G = root.children.last match + case g: Svg.G => g + case other => fail(s"Expected marks G but got $other") + val rects = marksG.children.flatMap: + case r: Svg.Rect => Chunk(r) + case _ => Chunk.empty + assert(rects.size == 3, s"Expected 3 bars but got ${rects.size}") + + // Band scale: n=3, slot=560/3=186.667, bandW=560*0.9/3=168, padding=(slot-bandW)/2=9.333 + val n = 3 + val slot = PlotW / n.toDouble + val bandW = PlotW * 0.9 / n.toDouble + val pad = (slot - bandW) / 2.0 + // First bar (2020): x = PlotX + 0*slot + pad + val expectedX0 = PlotX + 0 * slot + pad + assertClose(numOf(rects(0).svgAttrs.x), expectedX0, "band bar x for 2020") + assertClose(numOf(rects(0).svgAttrs.width), bandW, "band bar width") + + // Widths must all equal bandW (not varying as with a Linear scale) + rects.toSeq.foldLeft(()): (_, r) => + assertClose(numOf(r.svgAttrs.width), bandW, "all band bars same width") + } + } + + // ---- Test 4: yScale(_.log) produces log-spaced ticks at powers ---- + + "yScale(_.log) produces log-spaced ticks (assert ticks at powers of 10)" in { + // Data: revenue in [10, 100, 1000] -> Log scale + // Log(10, 1000, 440, 20): logMin=1, logMax=3 + // ticks at exp=1 (10, pixel=440), exp=2 (100, pixel=230), exp=3 (1000, pixel=20) + val rows = Chunk(Sale("a", Usd(10)), Sale("b", Usd(100)), Sale("c", Usd(1000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .yScale(_.log) + (spec).lower.map { root => + // Tick labels use TextAnchor.End for left axis + val tickLabels = frameTextsIn(root).filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End) + // Log ticks: 3 powers of 10 (1, 2, 3 -> 10, 100, 1000) + assert(tickLabels.size == 3, s"Expected 3 log-scale tick labels but got ${tickLabels.size}") + + // Tick y positions: 440, 230, 20 (for values 10, 100, 1000) + val tickLines = frameLinesIn(root).filter: l => + l.svgAttrs.x1.exists(_ < PlotX) && l.svgAttrs.y1 == l.svgAttrs.y2 + assert(tickLines.size == 3, s"Expected 3 log-scale tick marks but got ${tickLines.size}") + val tickYs = tickLines.map(l => l.svgAttrs.y1.getOrElse(0.0)).toSeq.sorted + assertClose(tickYs(0), 20.0, "log tick at 1000 (pixel 20)") + assertClose(tickYs(1), 230.0, "log tick at 100 (pixel 230)") + assertClose(tickYs(2), 440.0, "log tick at 10 (pixel 440)") + } + } + + // ---- Test 5: legend derives one swatch+label per enum case in enum order ---- + // colorScale assigns named swatch fills (N3 carry-over) + + "legend derives one swatch+label per Region enum case in enum order; colorScale assigns fills" in { + // Region cases in declaration order: NA (ordinal 0), EU (ordinal 1), APAC (ordinal 2) + // Rows supplied in out-of-order encounter order to verify ordinal sorting: + // APAC first, then NA, then EU -> legend must show NA, EU, APAC (ordinal order) + val rows = Chunk( + Sale("Jan", Usd(1000), Region.APAC), + Sale("Feb", Usd(800), Region.NA), + Sale("Mar", Usd(600), Region.EU) + ) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue, color = _.region)) + .legend( + _.colorScale[Region]( + Region.NA -> Style.Color.blue, + Region.EU -> Style.Color.green, + Region.APAC -> Style.Color.orange + ) + ) + (spec).lower.map { root => + // Legend swatches are Svg.Rect elements in the frame (not in the marks G) + // Frame rects: [0] = background rect, [1..] = legend swatches + val frameRects = frameRectsIn(root) + // First rect is the background; the next 3 are legend swatches (one per Region case) + val swatches = frameRects.drop(1) + assert(swatches.size == 3, s"Expected 3 legend swatches (NA, EU, APAC) but got ${swatches.size}") + + // Legend texts: 3 labels in enum ordinal order + val allTexts = frameTextsIn(root) + // Filter to texts that don't have textAnchor (legend labels have no textAnchor or DominantBaseline.Middle) + val legendLabels = allTexts.filter: t => + t.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Middle) && + t.svgAttrs.textAnchor.isEmpty + assert(legendLabels.size == 3, s"Expected 3 legend labels but got ${legendLabels.size}") + + // Colors must be in ordinal order: NA=blue, EU=green, APAC=orange + // Legend flows left-to-right: each successive swatch x must be greater than the previous. + val swatch0x = swatches(0).svgAttrs.x.map { case Coord.Num(v) => v; case _ => -1.0 }.getOrElse(-1.0) + val swatch1x = swatches(1).svgAttrs.x.map { case Coord.Num(v) => v; case _ => -1.0 }.getOrElse(-1.0) + assert( + swatch1x > swatch0x, + s"swatch(1).x ($swatch1x) must be greater than swatch(0).x ($swatch0x): legend items must flow left-to-right" + ) + assert(swatches(0).svgAttrs.fill.isDefined, "NA swatch must have fill") + assert(swatches(1).svgAttrs.fill.isDefined, "EU swatch must have fill") + assert(swatches(2).svgAttrs.fill.isDefined, "APAC swatch must have fill") + + // Assert specific fill colors via colorScale + assert(colorOf(swatches(0).svgAttrs.fill) == Style.Color.blue, s"NA swatch should be blue; got ${swatches(0).svgAttrs.fill}") + assert(colorOf(swatches(1).svgAttrs.fill) == Style.Color.green, s"EU swatch should be green; got ${swatches(1).svgAttrs.fill}") + assert( + colorOf(swatches(2).svgAttrs.fill) == Style.Color.orange, + s"APAC swatch should be orange; got ${swatches(2).svgAttrs.fill}" + ) + } + } + + // ---- Test 6: two axes yield distinct y-scales ---- + + "two axes: bar(revenue) + line(growthPct, Right) yield distinct y-scales; right labels on right margin" in { + // Two-axis layout: MarginR=60, plotW=520, plotX=60 + // Left: revenue=[0,2000]; Scale.Linear(0,2000,440,20); apply(10) = 440+(10/2000)*(20-440) = 437.9 + // Right: growthPct=[0,20]; niceTicks -> Linear(0,20,440,20); apply(10) = 440+(10/20)*(20-440) = 230 + // Both are numeric Doubles; they share the value 10 but map to different pixels + val rows = Chunk( + Row2Ax("Jan", Usd(1000), 10.0), + Row2Ax("Feb", Usd(2000), 20.0) + ) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ) + (spec).lower.map { root => + // Verify two independent y-scales by checking that the same value (10) maps to different pixels. + // Left scale: Linear(0,2000,440,20) -> apply(10) ≈ 437.9 + // Right scale: niceTicks(0,20,5)=step 5 -> Linear(0,20,440,20) -> apply(10) = 230 + val leftScaleY10 = 440.0 + (10.0 / 2000.0) * (20.0 - 440.0) // ≈ 437.9 + val rightScaleY10 = 440.0 + (10.0 / 20.0) * (20.0 - 440.0) // = 230.0 + assert( + math.abs(leftScaleY10 - rightScaleY10) > 100.0, + s"Left ($leftScaleY10) and right ($rightScaleY10) y-scales must differ significantly" + ) + + // Right axis tick labels appear on the right margin: x > plotX + plotW_twoax + val allTexts = frameTextsIn(root) + val rightTickLabels = allTexts.filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.Start) && + t.svgAttrs.x.exists { case Coord.Num(v) => v > PlotX + PlotWTwoAx; case _ => false } + assert(rightTickLabels.nonEmpty, s"Expected right-axis tick labels past plotX+plotW but found none") + + // Left tick labels appear to the left of plotX: x < plotX + val leftTickLabels = allTexts.filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End) && + t.svgAttrs.x.exists { case Coord.Num(v) => v < PlotX; case _ => false } + assert(leftTickLabels.nonEmpty, s"Expected left-axis tick labels left of plotX but found none") + } + } + + // ---- dual-axis chrome is color-coded to its single bound mark ---- + // In a dual-axis combo (bar on left = mark 0, line on right = mark 1) the left axis chrome must take + // the bar's palette color (palette(0) = blue) and the right axis chrome the line's palette color + // (palette(1) = orange), so a reader can tell which y-axis each series uses. The x-axis stays neutral. + + "dual-axis combo color-codes each y-axis chrome to its single bound mark (left=palette(0), right=palette(1))" in { + // Neutral light-theme chrome color, matching ChartLower.LightThemeTextColor (#374151). + val neutral = Style.Color.hex("#374151").getOrElse(Style.Color.black) + val rows = Chunk( + Row2Ax("Jan", Usd(1000), 10.0), + Row2Ax("Feb", Usd(2000), 20.0) + ) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ) + .yAxis(_.label("Revenue")) + .yAxisRight(_.label("Growth %")) + (spec).lower.map { root => + val texts = frameTextsIn(root) + + // Left axis tick labels: TextAnchor.End at x < plotX. Their fill must be palette(0) = blue. + val leftTickLabels = texts.filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End) && + t.svgAttrs.x.exists { case Coord.Num(v) => v < PlotX; case _ => false } + assert(leftTickLabels.nonEmpty, "Expected left-axis tick labels") + leftTickLabels.foldLeft(()): (_, t) => + assert( + colorOf(t.svgAttrs.fill) == Style.Color.blue, + s"Left tick label should be palette(0) (blue) but was ${t.svgAttrs.fill}" + ) + + // Right axis tick labels: TextAnchor.Start at x > plotX + plotW. Their fill must be palette(1) = orange. + val rightTickLabels = texts.filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.Start) && + t.svgAttrs.x.exists { case Coord.Num(v) => v > PlotX + PlotWTwoAx; case _ => false } + assert(rightTickLabels.nonEmpty, "Expected right-axis tick labels") + rightTickLabels.foldLeft(()): (_, t) => + assert( + colorOf(t.svgAttrs.fill) == Style.Color.orange, + s"Right tick label should be palette(1) (orange) but was ${t.svgAttrs.fill}" + ) + + // The rotated "Revenue" (left) label must be blue and "Growth %" (right) label orange. + def isRotated(t: Svg.Text): Boolean = t.svgAttrs.transform.toSeq.exists: + case _: Svg.Transform.Rotate => true + case _ => false + val revenueLabel = texts.find(t => + isRotated(t) && t.children.exists { case UI.Ast.Text("Revenue") => true; case _ => false } + ).getOrElse(fail("Expected a rotated 'Revenue' axis label")) + val growthLabel = texts.find(t => + isRotated(t) && t.children.exists { case UI.Ast.Text("Growth %") => true; case _ => false } + ).getOrElse(fail("Expected a rotated 'Growth %' axis label")) + assert( + colorOf(revenueLabel.svgAttrs.fill) == Style.Color.blue, + s"Left axis label should be blue but was ${revenueLabel.svgAttrs.fill}" + ) + assert( + colorOf(growthLabel.svgAttrs.fill) == Style.Color.orange, + s"Right axis label should be orange but was ${growthLabel.svgAttrs.fill}" + ) + + // The shared x-axis chrome stays neutral (not tied to either series color). + val xTickLabels = texts.filter(t => t.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Hanging)) + assert(xTickLabels.nonEmpty, "Expected x-axis tick labels") + xTickLabels.foldLeft(()): (_, t) => + assert(colorOf(t.svgAttrs.fill) == neutral, s"X-axis tick label should stay neutral ($neutral) but was ${t.svgAttrs.fill}") + } + } + + // ---- rotated axis labels sit at the outer margin edge, clear of tick numbers ---- + // The dual-axis gallery cell is 360x240 with MarginLeft=60 and a right-axis margin (MarginRight=60). + // The rotated "Revenue" label must sit near the left SVG edge (x in [12,16]) and the rotated + // "Growth %" label near the right SVG edge, so neither overlaps the tick numbers, which sit + // adjacent to the axis line (left numbers extend left from x=60; right numbers extend right from x=300). + + "rotated y-axis labels sit at the outer margin edge, clear of the tick numbers (360x240 dual-axis)" in { + val rows = Chunk( + Row2Ax("Jan", Usd(45000), 0.0), + Row2Ax("Feb", Usd(52000), 15.6), + Row2Ax("Mar", Usd(48000), -7.7) + ) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ) + .yAxis(_.label("Revenue")) + .yAxisRight(_.label("Growth %")) + .size(360, 240) + (spec).lower.map { root => + val texts = frameTextsIn(root) + + // The rotated axis labels are the texts carrying a Transform.Rotate. + def isRotated(t: Svg.Text): Boolean = t.svgAttrs.transform.toSeq.exists: + case _: Svg.Transform.Rotate => true + case _ => false + + val revenueLabel = texts.find(t => + isRotated(t) && t.children.exists { + case UI.Ast.Text("Revenue") => true; case _ => false + } + ).getOrElse(fail("Expected a rotated 'Revenue' axis label")) + val growthLabel = texts.find(t => + isRotated(t) && t.children.exists { + case UI.Ast.Text("Growth %") => true; case _ => false + } + ).getOrElse(fail("Expected a rotated 'Growth %' axis label")) + + // Left "Revenue" label centred near the left SVG edge (x in [12,16]). + val revenueX = numOf(revenueLabel.svgAttrs.x) + assert(revenueX >= 12.0 && revenueX <= 16.0, s"Revenue label x should be in [12,16] but was $revenueX") + + // Right "Growth %" label centred near the right SVG edge (x near 360), past the right tick numbers. + // Right axis line is at plotX+plotW = 60 + (360-60-60) = 300; tick numbers extend right from x=309. + val growthX = numOf(growthLabel.svgAttrs.x) + assert(growthX >= 340.0 && growthX <= 360.0, s"Growth %% label x should be near the right edge [340,360] but was $growthX") + + // The labels must clear the tick numbers: left ticks are the End-anchored numeric texts (the rotated + // axis labels are Middle-anchored, so anchor=End isolates the tick numbers). The Revenue label centre + // at ~14 must be left of every tick-number anchor. + // NOTE: the left margin auto-grows to fit tick labels so the 5-digit "50000" tick label does not + // clip the rotated title. The tick-number anchor sits at ~61 (plotX 70 minus + // TickLen+gap), so a `< PlotX` (60) filter that assumes plotX stays at the default 60 would not + // hold here; an anchor-only filter is used instead. + val leftTickLabels = texts.filter(t => t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End)) + assert(leftTickLabels.nonEmpty, "Expected left-axis tick numbers") + leftTickLabels.foldLeft(()): (_, t) => + val tx = numOf(t.svgAttrs.x) + assert(revenueX < tx, s"Revenue label ($revenueX) must be left of tick number anchor ($tx)") + } + } + + // ---- Test 7: theme(_.dark) sets background rect fill to the dark color ---- + + "theme(_.dark) sets the background rect fill to the dark theme color" in { + // DarkBg = Style.Color.hex("#1f2937").getOrElse(Style.Color.black) + val darkBg = Style.Color.hex("#1f2937").getOrElse(Style.Color.black) + val rows = Chunk(Sale("Jan", Usd(1000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .theme(_.dark) + (spec).lower.map { root => + // Background is the first frame Rect + val frameRects = frameRectsIn(root) + assert(frameRects.nonEmpty, "Expected at least one frame rect (background)") + val bg = frameRects(0) + assert(bg.svgAttrs.fill.isDefined, "Background rect must have a fill") + assert(colorOf(bg.svgAttrs.fill) == darkBg, s"Dark theme background should be $darkBg but got ${bg.svgAttrs.fill}") + } + } + + // ---- stacked bars accumulate y0/y1 per group ---- + + "stacked bars: per-group rects accumulate (second group sits atop the first)" in { + // Data: two groups A and B at x="Jan" + // A: 300, B: 700 (total=1000) + // stackedYExtent -> max=1000 + // niceTicks(0,1000,5): step=250 -> Linear(0,1000,440,20) + // ys(300)=314, ys(700)=146, ys(1000)=20 + // Groups ordered by enum ordinal: A (encounters row 0, ordinal=encounter 0=0), + // B (encounters row 1, ordinal=1) -- since String, encounter order preserved + // Group A (gi=0): accY: 0->300; rectBot=440 (baseline), rectTop=ys(300)=314; height=126 + // Group B (gi=1): accY: 300->1000; rectBot=ys(300)=314, rectTop=ys(1000)=20; height=294 + case class SRow(x: String, group: String, value: Double) + val rows = Chunk( + SRow("Jan", "A", 300.0), + SRow("Jan", "B", 700.0) + ) + // Legend hidden so this test isolates stack geometry (a default legend would reserve top space and + // shift plotY/plotH; the stacked-legend derivation is covered by the dedicated tests above). + val spec = Chart(rows)(bar( + x = _.x, + y = _.value, + stack = by(_.group) + )).legend(_.hidden) + (spec).lower.map { root => + val marksG: Svg.G = root.children.last match + case g: Svg.G => g + case other => fail(s"Expected marks G but got $other") + val rects = marksG.children.flatMap: + case r: Svg.Rect => Chunk(r) + case _ => Chunk.empty + // One x group ("Jan"), two stack groups (A, B) + assert(rects.size == 2, s"Expected 2 stacked rects but got ${rects.size}") + + // niceTicks(0,1000,5): rawStep=250, step=250; Linear(0,1000,440,20) + def ys(v: Double) = 440.0 + (v / 1000.0) * (20.0 - 440.0) + + // Group A (index 0): rectTop=ys(300), rectBot=440 + val rA = rects(0) + assertClose(numOf(rA.svgAttrs.y), ys(300.0), "Group A rect top (ys(300))") + assertClose(numOf(rA.svgAttrs.height), Baseline - ys(300.0), "Group A rect height (baseline - ys(300))") + + // Group B (index 1): sits atop A; rectTop=ys(1000), rectBot=ys(300) + val rB = rects(1) + assertClose(numOf(rB.svgAttrs.y), ys(1000.0), "Group B rect top (ys(1000))") + assertClose(numOf(rB.svgAttrs.height), ys(300.0) - ys(1000.0), "Group B rect height (ys(300)-ys(1000))") + } + } + + // ---- stacked bar derives a legend from the stack groups ---- + + "stacked bar with .legend(_.top): one swatch per stack category in the segment colors" in { + // A stacked bar grouped by `group` (no separate `color` encoding) must derive its legend from the + // STACK groups, exactly as a `color` encoding would: one swatch per stack category, in the colors the + // stacked segments use. Three groups A, B, C at x="Jan". + case class SRow(x: String, group: String, value: Double) + val rows = Chunk( + SRow("Jan", "A", 300.0), + SRow("Jan", "B", 500.0), + SRow("Jan", "C", 200.0) + ) + val spec = Chart(rows)(bar( + x = _.x, + y = _.value, + stack = by(_.group) + )).legend(_.top) + (spec).lower.map { root => + // Legend swatches are frame rects after the background rect ([0]=background, [1..]=swatches). + val frameRects = frameRectsIn(root) + val swatches = frameRects.drop(1) + assert(swatches.size == 3, s"Expected 3 legend swatches (A, B, C) but got ${swatches.size}") + + // The stacked segment rects live in the marks G; their fills are the segment colors per group. + val marksG: Svg.G = root.children.last match + case g: Svg.G => g + case other => fail(s"Expected marks G but got $other") + val segments = marksG.children.flatMap: + case r: Svg.Rect => Chunk(r) + case _ => Chunk.empty + assert(segments.size == 3, s"Expected 3 stacked segments but got ${segments.size}") + + // Each swatch color must equal the color of the corresponding stacked segment (group ordinal order): + // segment 0 = group A, segment 1 = group B, segment 2 = group C. + assert(colorOf(swatches(0).svgAttrs.fill) == colorOf(segments(0).svgAttrs.fill), "A swatch must match A segment color") + assert(colorOf(swatches(1).svgAttrs.fill) == colorOf(segments(1).svgAttrs.fill), "B swatch must match B segment color") + assert(colorOf(swatches(2).svgAttrs.fill) == colorOf(segments(2).svgAttrs.fill), "C swatch must match C segment color") + + // And those colors are the default palette in group order (no explicit colorScale). + assert(colorOf(swatches(0).svgAttrs.fill) == Style.Color.blue, "A swatch should be palette(0)=blue") + assert(colorOf(swatches(1).svgAttrs.fill) == Style.Color.orange, "B swatch should be palette(1)=orange") + assert(colorOf(swatches(2).svgAttrs.fill) == Style.Color.green, "C swatch should be palette(2)=green") + + // Three legend labels, one per group. + val legendLabels = frameTextsIn(root).filter: t => + t.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Middle) && t.svgAttrs.textAnchor.isEmpty + assert(legendLabels.size == 3, s"Expected 3 legend labels but got ${legendLabels.size}") + } + } + + // ---- stacked bar legend honors an explicit colorScale ---- + + "stacked bar legend + segments honor .colorScale (semantic colors)" in { + // The stack legend and the segments must use the SAME explicit colorScale mapping, so monitoring + // dashboards can map e.g. 2xx->green, 4xx->amber, 5xx->red. + case class SRow(x: String, code: String, count: Double) + val rows = Chunk( + SRow("/a", "2xx", 90.0), + SRow("/a", "4xx", 8.0), + SRow("/a", "5xx", 2.0) + ) + val amber = Style.Color.hex("#f59e0b").getOrElse(Style.Color.orange) + val spec = Chart(rows)(bar( + x = _.x, + y = _.count, + stack = by(_.code) + )).legend( + _.top.colorScale { + case "2xx" => Style.Color.green + case "4xx" => amber + case _ => Style.Color.red + } + ) + (spec).lower.map { root => + val swatches = frameRectsIn(root).drop(1) + assert(swatches.size == 3, s"Expected 3 legend swatches but got ${swatches.size}") + assert(colorOf(swatches(0).svgAttrs.fill) == Style.Color.green, "2xx swatch green") + assert(colorOf(swatches(1).svgAttrs.fill) == amber, "4xx swatch amber") + assert(colorOf(swatches(2).svgAttrs.fill) == Style.Color.red, "5xx swatch red") + + val marksG: Svg.G = root.children.last match + case g: Svg.G => g + case other => fail(s"Expected marks G but got $other") + val segments = marksG.children.flatMap: + case r: Svg.Rect => Chunk(r) + case _ => Chunk.empty + assert(segments.size == 3, s"Expected 3 stacked segments but got ${segments.size}") + assert(colorOf(segments(0).svgAttrs.fill) == Style.Color.green, "2xx segment green") + assert(colorOf(segments(1).svgAttrs.fill) == amber, "4xx segment amber") + assert(colorOf(segments(2).svgAttrs.fill) == Style.Color.red, "5xx segment red") + } + } + + // ---- legend label text uses the light theme chrome color on dark ---- + + "dark theme legend labels use the light theme chrome color (not black), matching axis tick labels" in { + // The dark-theme background panel (#1f2937) makes a black label invisible. The legend label text + // must take the SAME theme chrome color the axis tick labels use (DarkThemeTextColor #e5e7eb), + // while swatch fills stay the category/colorScale colors. + val darkText = Style.Color.hex("#e5e7eb").getOrElse(Style.Color.white) + case class SRow(x: String, code: String, count: Double) + val rows = Chunk( + SRow("/a", "2xx", 90.0), + SRow("/a", "4xx", 8.0), + SRow("/a", "5xx", 2.0) + ) + val amber = Style.Color.hex("#f59e0b").getOrElse(Style.Color.orange) + val spec = Chart(rows)(bar( + x = _.x, + y = _.count, + stack = by(_.code) + )).theme(_.dark).legend( + _.top.colorScale { + case "2xx" => Style.Color.green + case "4xx" => amber + case _ => Style.Color.red + } + ) + (spec).lower.map { root => + // Legend labels: frame texts with DominantBaseline.Middle and no textAnchor. + val legendLabels = frameTextsIn(root).filter: t => + t.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Middle) && t.svgAttrs.textAnchor.isEmpty + assert(legendLabels.size == 3, s"Expected 3 legend labels but got ${legendLabels.size}") + legendLabels.foreach: t => + assert( + colorOf(t.svgAttrs.fill) == darkText, + s"Dark theme legend label fill should be the light chrome color $darkText but was ${t.svgAttrs.fill}" + ) + + // The axis tick labels on the same dark chart use the SAME chrome color; the legend now matches. + val tickLabels = frameTextsIn(root).filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.Middle) + assert(tickLabels.nonEmpty, "Expected at least one x-axis tick label") + tickLabels.foreach: t => + assert( + colorOf(t.svgAttrs.fill) == darkText, + s"Dark theme axis tick label fill should be $darkText but was ${t.svgAttrs.fill}" + ) + + // Swatch fills stay the colorScale colors, not the chrome color. + val swatches = frameRectsIn(root).drop(1) + assert(swatches.size == 3, s"Expected 3 legend swatches but got ${swatches.size}") + assert(colorOf(swatches(0).svgAttrs.fill) == Style.Color.green, "2xx swatch stays green") + assert(colorOf(swatches(1).svgAttrs.fill) == amber, "4xx swatch stays amber") + assert(colorOf(swatches(2).svgAttrs.fill) == Style.Color.red, "5xx swatch stays red") + } + } + + // ---- tickFormat receives domain value, not pixel ---- + + "tickFormat receives the domain value (e.g. 2000), not the pixel position" in { + // Domain [0, 2000], ticks(3): niceTicks(0,2000,3) -> [0, 1000, 2000] + // Linear(0, 2000, 440, 20): apply(2000) = 20.0 (a pixel), not 2000.0 + // The formatter v => s"$$${v.toInt}" should produce "$2000" from domain 2000, not "$20" + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .yAxis(_.ticks(3).format(v => s"$$${v.toInt}")) + (spec).lower.map { root => + // Left-axis tick labels have TextAnchor.End + val tickLabels = frameTextsIn(root).filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End) + assert(tickLabels.size == 3, s"Expected 3 formatted tick labels but got ${tickLabels.size}") + + // The label for the top tick (domain value 2000) must be "$2000", not "$20" + // Tick texts are rendered in tick order; find the one whose y coordinate matches apply(2000) = 20.0 + val topTickTexts = tickLabels.filter: t => + t.svgAttrs.y.exists: + case Coord.Num(v) => math.abs(v - 20.0) < Tol + case _ => false + assert(topTickTexts.size == 1, s"Expected exactly 1 tick label at y=20.0 but got ${topTickTexts.size}") + + // Verify the text content is "$2000" (domain value formatted), not "$20" (pixel formatted) + val topText = topTickTexts(0) + val content = topText.children.headOption match + case Some(UI.Ast.Text(s)) => s + case other => fail(s"Expected UI.Ast.Text but got $other") + assert(content == "$2000", s"Formatter received pixel instead of domain value; got '$content' expected '$$2000'") + } + } + + // ---- normalize=true -> fills full plot height ---- + + "normalize=true stacked bars fill the full plot height (top group reaches plotY)" in { + // Same data as Test 8: Jan A=300(30%), B=700(70%) + // normalize=true: each x slot fills plotH + // Group A: accY: 0->0.3; + // rectTop = plotY + (1-0.3)*plotH = 20 + 0.7*420 = 20+294 = 314 + // rectBot = plotY + (1-0)*plotH = 20+420 = 440 + // height = 126 + // Group B: accY: 0.3->1.0 + // rectTop = plotY + (1-1)*plotH = 20 + // rectBot = plotY + (1-0.3)*plotH = 314 + // height = 294 + // Top of Group B = plotY = 20 (reaches the top of the plot) + case class SRow(x: String, group: String, value: Double) + val rows = Chunk( + SRow("Jan", "A", 300.0), + SRow("Jan", "B", 700.0) + ) + // Legend hidden to isolate stack geometry (see the stacked-bar accumulation test for the rationale). + val spec = Chart(rows)(bar( + x = _.x, + y = _.value, + stack = by(_.group, normalize = true) + )).legend(_.hidden) + (spec).lower.map { root => + val marksG: Svg.G = root.children.last match + case g: Svg.G => g + case other => fail(s"Expected marks G but got $other") + val rects = marksG.children.flatMap: + case r: Svg.Rect => Chunk(r) + case _ => Chunk.empty + assert(rects.size == 2, s"Expected 2 normalized stacked rects but got ${rects.size}") + + // Group A: rectTop=314, height=126 + val rA = rects(0) + assertClose(numOf(rA.svgAttrs.y), 314.0, "Normalized Group A rectTop") + assertClose(numOf(rA.svgAttrs.height), 126.0, "Normalized Group A height") + + // Group B: rectTop=20 (= plotY), height=294 + val rB = rects(1) + assertClose(numOf(rB.svgAttrs.y), PlotY, "Normalized Group B rectTop == plotY (fills to top)") + assertClose(numOf(rB.svgAttrs.height), 294.0, "Normalized Group B height") + + // Together they cover the full plot height + assertClose( + numOf(rA.svgAttrs.height) + numOf(rB.svgAttrs.height), + PlotH, + "Normalized stack total height == plotH" + ) + } + } + + // ---- stacked area -- second group baseline equals first group top edge ---- + + "stacked area: second group baseline equals first group top edge at shared x" in { + // Two groups A and B at x=1 and x=2; each x has A=300, B=700 (total=1000). + // stackedAreaYExtent -> max=1000; niceTicks(0,1000,5) -> step=250; Linear(0,1000,440,20) + // ys(300) = 440 + (300/1000)*(20-440) = 440 - 126 = 314 + // ys(1000)= 440 + (1000/1000)*(20-440) = 20 + // Group A (gi=0): accY=0->300; y0=baseline=440; y1=ys(300)=314 + // Top edge of A at x=1 is pixel y=314 (i.e. domain 300 mapped) + // Group B (gi=1): accY=300->1000; y0=ys(300)=314; y1=ys(1000)=20 + // Bottom edge of B at x=1 is pixel y=314 (same as top of A) + case class ARow(x: Int, group: String, value: Double) + val rows = Chunk( + ARow(1, "A", 300.0), + ARow(1, "B", 700.0), + ARow(2, "A", 300.0), + ARow(2, "B", 700.0) + ) + // Legend hidden to isolate stack geometry (a stacked area now derives a legend by default). + val spec = Chart(rows)(area( + x = _.x, + y = _.value, + stack = by(_.group) + )).legend(_.hidden) + (spec).lower.map { root => + val marksG: Svg.G = root.children.last match + case g: Svg.G => g + case other => fail(s"Expected marks G but got $other") + val paths = marksG.children.flatMap: + case p: Svg.Path => Chunk(p) + case _ => Chunk.empty + // Two groups -> two area paths + assert(paths.size == 2, s"Expected 2 stacked area paths but got ${paths.size}") + + // For exact pixel math: Linear(0, 1000, 440, 20) + def ys(v: Double) = 440.0 + (v / 1000.0) * (20.0 - 440.0) + + // Group A (path 0): top edge at y=ys(300)=314; baseline at y=440 + // Group B (path 1): top edge at y=ys(1000)=20; bottom at y=ys(300)=314 + // Verify that the path data for group A contains a point at pixel y=ys(300) + // and group B contains a point at pixel y=ys(300) as its baseline. + // We check by extracting the PathData commands and finding the pixel values. + val pathACommands = Svg.PathData.commands(paths(0).svgAttrs.d.getOrElse(Svg.PathData.empty)) + val pathBCommands = Svg.PathData.commands(paths(1).svgAttrs.d.getOrElse(Svg.PathData.empty)) + + // The top edge of path A starts at y=ys(300). The path begins with a MoveTo or first LineTo at that y. + val aYs = pathACommands.flatMap: + case PathCommand.MoveTo(_, y) => Chunk(y) + case PathCommand.LineTo(_, y) => Chunk(y) + case _ => Chunk.empty + assert( + aYs.toSeq.exists(y => math.abs(y - ys(300.0)) < Tol), + s"Group A path must contain top-edge y=ys(300)=${ys(300.0)} but got: $aYs" + ) + + // Group B's baseline (bottom of path B) must be at y=ys(300)=314 (same as top of A). + val bYs = pathBCommands.flatMap: + case PathCommand.MoveTo(_, y) => Chunk(y) + case PathCommand.LineTo(_, y) => Chunk(y) + case _ => Chunk.empty + assert( + bYs.toSeq.exists(y => math.abs(y - ys(300.0)) < Tol), + s"Group B path must contain baseline y=ys(300)=${ys(300.0)} but got: $bYs" + ) + } + } + + // ---- stacked area normalized -- top group reaches plotY ---- + + "stacked area normalized: top group reaches plotY" in { + // Same data: A=300, B=700 at each x; normalize=true + // Group A (gi=0): fraction 0.3; y1 = plotY + (1-0.3)*plotH = 20+294=314; y0 = plotY+plotH=440 + // Group B (gi=1): fraction 0.7; y1 = plotY + (1-1.0)*plotH = 20; y0 = 314 + // Top of Group B = plotY = 20 (reaches the very top of the plot area) + case class ARow(x: Int, group: String, value: Double) + val rows = Chunk( + ARow(1, "A", 300.0), + ARow(1, "B", 700.0) + ) + // Legend hidden to isolate stack geometry (a stacked area now derives a legend by default). + val spec = Chart(rows)(area( + x = _.x, + y = _.value, + stack = by(_.group, normalize = true) + )).legend(_.hidden) + (spec).lower.map { root => + val marksG: Svg.G = root.children.last match + case g: Svg.G => g + case other => fail(s"Expected marks G but got $other") + val paths = marksG.children.flatMap: + case p: Svg.Path => Chunk(p) + case _ => Chunk.empty + assert(paths.size == 2, s"Expected 2 normalized stacked area paths but got ${paths.size}") + + // Group B (index 1) is the top group and its top edge must reach plotY = 20 + val pathBCommands = Svg.PathData.commands(paths(1).svgAttrs.d.getOrElse(Svg.PathData.empty)) + val bYs = pathBCommands.flatMap: + case PathCommand.MoveTo(_, y) => Chunk(y) + case PathCommand.LineTo(_, y) => Chunk(y) + case _ => Chunk.empty + assert( + bYs.toSeq.exists(y => math.abs(y - PlotY) < Tol), + s"Normalized top group (B) must reach plotY=$PlotY but path y-values are: $bYs" + ) + } + } + + // ---- a GROUPED bar's y-axis chrome stays NEUTRAL, not palette(0) ---- + // A grouped bar (a single bar mark WITH a color encoding) renders in multiple category colors, so painting + // its y-axis a single palette color (blue) would misrepresent the series. The y-axis chrome must use the + // neutral light-theme color instead. This tightens the iteration-2 rule, which color-coded any single + // bound mark regardless of whether it rendered as one solid color. + + "grouped bar (color encoding) keeps a NEUTRAL y-axis chrome, not palette(0)" in { + val neutral = Style.Color.hex("#374151").getOrElse(Style.Color.black) + val rows = Chunk( + Sale("Jan", Usd(1000), Region.NA), + Sale("Jan", Usd(2000), Region.EU), + Sale("Jan", Usd(1500), Region.APAC) + ) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue, color = _.region)) + (spec).lower.map { root => + // Left y-axis tick labels use TextAnchor.End. Their fill must be the neutral chrome, not blue. + val leftTickLabels = frameTextsIn(root).filter(_.svgAttrs.textAnchor.contains(Svg.TextAnchor.End)) + assert(leftTickLabels.nonEmpty, "Expected left y-axis tick labels for the grouped bar") + leftTickLabels.foldLeft(()): (_, t) => + val c = colorOf(t.svgAttrs.fill) + assert(c == neutral, s"Grouped-bar y-axis tick should be neutral ($neutral) but was $c") + assert(c != Style.Color.blue, s"Grouped-bar y-axis tick must NOT be palette(0) (blue) but was $c") + } + } + + // ---- a STACKED bar's y-axis chrome stays NEUTRAL, not palette(0) ---- + // A stacked bar (a single bar mark WITH a stack grouping) also renders in multiple category colors, so its + // y-axis chrome must use the neutral color rather than a single palette color. + + "stacked bar (stack grouping) keeps a NEUTRAL y-axis chrome, not palette(0)" in { + val neutral = Style.Color.hex("#374151").getOrElse(Style.Color.black) + case class SRow(x: String, group: String, value: Double) + given CanEqual[SRow, SRow] = CanEqual.derived + val rows = Chunk( + SRow("Jan", "A", 300.0), + SRow("Jan", "B", 700.0) + ) + val spec = Chart(rows)(bar(x = _.x, y = _.value, stack = by(_.group))) + (spec).lower.map { root => + val leftTickLabels = frameTextsIn(root).filter(_.svgAttrs.textAnchor.contains(Svg.TextAnchor.End)) + assert(leftTickLabels.nonEmpty, "Expected left y-axis tick labels for the stacked bar") + leftTickLabels.foldLeft(()): (_, t) => + val c = colorOf(t.svgAttrs.fill) + assert(c == neutral, s"Stacked-bar y-axis tick should be neutral ($neutral) but was $c") + assert(c != Style.Color.blue, s"Stacked-bar y-axis tick must NOT be palette(0) (blue) but was $c") + } + } + + // ---- a single-color line keeps its color-coded y-axis chrome ---- + // A line mark with no color encoding renders as one solid color, so it still color-codes its y-axis + // chrome (tick labels) to that mark's palette color (palette(0) = blue). This confirms the tightened rule + // does not over-correct: solid-color marks remain color-coded. + + "single-color line keeps its y-axis chrome color-coded to palette(0) (blue)" in { + case class LRow(month: String, value: Double) + given CanEqual[LRow, LRow] = CanEqual.derived + val rows = Chunk(LRow("Jan", 100.0), LRow("Feb", 200.0), LRow("Mar", 150.0)) + val spec = Chart(rows)(line(x = _.month, y = _.value)).yAxis(_.grid) + (spec).lower.map { root => + val leftTickLabels = frameTextsIn(root).filter(_.svgAttrs.textAnchor.contains(Svg.TextAnchor.End)) + assert(leftTickLabels.nonEmpty, "Expected left y-axis tick labels for the line chart") + leftTickLabels.foldLeft(()): (_, t) => + assert( + colorOf(t.svgAttrs.fill) == Style.Color.blue, + s"Single-color line y-axis tick should be palette(0) (blue) but was ${t.svgAttrs.fill}" + ) + } + } + + // ---- gridlines are ALWAYS neutral, even when the axis chrome is color-coded ---- + // Gridlines are a background reference, not axis identity. In a single-color line chart the y-axis tick + // labels are color-coded to palette(0) (blue), but the gridlines must stay the neutral gridline color, not + // inherit the color-coded chrome (no blue gridlines). + + "gridlines in a color-coded line chart use the neutral grid color, not palette(0)" in { + val neutral = Style.Color.hex("#374151").getOrElse(Style.Color.black) + case class LRow(month: String, value: Double) + given CanEqual[LRow, LRow] = CanEqual.derived + val rows = Chunk(LRow("Jan", 100.0), LRow("Feb", 200.0), LRow("Mar", 150.0)) + val spec = Chart(rows)(line(x = _.month, y = _.value)).yAxis(_.grid.ticks(3)) + (spec).lower.map { root => + // Gridlines span the full plot width and carry a strokeOpacity (distinct from axis lines). + val gridLines = frameLinesIn(root).filter: l => + l.svgAttrs.x1.exists(_ == PlotX) && l.svgAttrs.x2.exists(_ == PlotX + PlotW) && + l.svgAttrs.y1 == l.svgAttrs.y2 && l.svgAttrs.strokeOpacity.isDefined + assert(gridLines.nonEmpty, "Expected gridlines in the line chart") + + // Confirm the y-axis chrome IS color-coded (tick labels blue) so the gridline check is meaningful. + val leftTickLabels = frameTextsIn(root).filter(_.svgAttrs.textAnchor.contains(Svg.TextAnchor.End)) + assert(leftTickLabels.nonEmpty, "Expected color-coded left tick labels") + assert( + colorOf(leftTickLabels(0).svgAttrs.fill) == Style.Color.blue, + "Precondition: line-chart y-axis tick labels must be color-coded (blue)" + ) + + // Despite the color-coded chrome, gridlines stay neutral, never palette(0). + gridLines.foldLeft(()): (_, l) => + val c = colorOf(l.svgAttrs.stroke) + assert(c == neutral, s"Gridline stroke should be the neutral grid color ($neutral) but was $c") + assert(c != Style.Color.blue, s"Gridline must NOT be palette(0) (blue) but was $c") + } + } + + // ---- X-axis decoration helpers ---- + + /** X-axis tick labels: bottom texts with the Hanging dominant baseline. */ + private def xTickLabelsIn(root: Svg.Root): Chunk[Svg.Text] = + frameTextsIn(root).filter(_.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Hanging)) + + private def circlesIn(root: Svg.Root): Chunk[Svg.Circle] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case c: Svg.Circle => Chunk(c) + case _ => Chunk.empty + case _ => Chunk.empty + + private def barsIn(root: Svg.Root): Chunk[Svg.Rect] = + root.children.last match + case g: Svg.G => + g.children.flatMap: + case r: Svg.Rect => Chunk(r) + case _ => Chunk.empty + case _ => Chunk.empty + + // ---- rotateTicks adds a rotate transform on x tick labels ---- + + "xAxis(_.rotateTicks(-45)) gives every x tick label a Rotate(-45) transform" in { + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)).xAxis(_.rotateTicks(-45.0)) + (spec).lower.map { root => + val labels = xTickLabelsIn(root) + assert(labels.nonEmpty, "Expected x tick labels") + labels.foldLeft(()): (_, t) => + val rot = t.svgAttrs.transform.toSeq.collectFirst { case r: Svg.Transform.Rotate => r } + rot match + case Some(r) => assertClose(r.deg, -45.0, "x tick label rotate degrees") + case None => fail(s"Expected a Rotate transform on tick label but got ${t.svgAttrs.transform}") + } + } + + // ---- anchor sets the SVG text-anchor on x tick labels ---- + + "xAxis(_.anchor(TextAnchor.End)) sets text-anchor=end on x tick labels" in { + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)).xAxis(_.anchor(TextAnchor.End)) + (spec).lower.map { root => + val labels = xTickLabelsIn(root) + assert(labels.nonEmpty, "Expected x tick labels") + labels.foldLeft(()): (_, t) => + assert(t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End), s"Expected text-anchor=end but got ${t.svgAttrs.textAnchor}") + } + } + + // ---- x gridlines at each tick from plotY to plotBaseline ---- + + "xAxis(_.grid) emits vertical gridlines at each x tick from plotY to plotBaseline" in { + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000)), Sale("Mar", Usd(1500))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)).xAxis(_.grid) + (spec).lower.map { root => + // Vertical gridlines: x1==x2, y1==plotY, y2==plotBaseline, with a strokeOpacity. + val vGrid = frameLinesIn(root).filter: l => + l.svgAttrs.x1 == l.svgAttrs.x2 && + l.svgAttrs.y1.exists(_ == PlotY) && l.svgAttrs.y2.exists(_ == Baseline) && + l.svgAttrs.strokeOpacity.isDefined + // 3 bands -> 3 tick gridlines. + assert(vGrid.size == 3, s"Expected 3 vertical gridlines but got ${vGrid.size}") + } + } + + // ---- yAxis(_.reverse) places the first datum at the far pixel end ---- + + "yAxis(_.reverse) swaps the y range so a small value sits near the top, not the baseline" in { + // Without reverse, y=0 maps near baseline (440); with reverse, the range swaps so y=0 maps near top (20). + val rows = Chunk(Sale("Jan", Usd(0)), Sale("Feb", Usd(2000))) + val normal = Chart(rows)(bar(x = _.month, y = _.revenue)) + val flipped = Chart(rows)(bar(x = _.month, y = _.revenue)).yAxis(_.reverse) + for + rootNorm <- (normal).lower + rootFlip <- (flipped).lower + yield + val rNorm = barsIn(rootNorm) + val rFlip = barsIn(rootFlip) + // The Jan bar (value 0): normal top y is at the baseline; reversed top y is at the plot top. + val janNormalY = numOf(rNorm(0).svgAttrs.y) + val janFlipY = numOf(rFlip(0).svgAttrs.y) + assert(janFlipY < janNormalY, s"Reversed Jan-bar y ($janFlipY) should be nearer the top than normal ($janNormalY)") + assertClose(janFlipY, PlotY, "reversed zero datum sits at plot top") + end for + } + + // ---- xAxis(_.pad) widens the domain so the first datum is inset ---- + + "xScale linear with pad insets the first datum from the plot edge (continuous x)" in { + case class XRow(x: Double, y: Double) + val rows = Chunk(XRow(0.0, 1.0), XRow(10.0, 2.0)) + val noPad = Chart(rows)(point(x = _.x, y = _.y)) + val padded = Chart(rows)(point(x = _.x, y = _.y)).xScale(_.withPad(0.1)) + for + rootNoPad <- (noPad).lower + rootPadded <- (padded).lower + yield + val cxNoPad = circlesIn(rootNoPad)(0).svgAttrs.cx + val cxPadded = circlesIn(rootPadded)(0).svgAttrs.cx + val noPadX = cxNoPad match + case Present(v) => v; + case Absent => fail("cx") + val padX = cxPadded match + case Present(v) => v; + case Absent => fail("cx") + // Padding widens the domain symmetrically, so the first datum moves inward (to a larger pixel). + assert(padX > noPadX, s"Padded first-datum cx ($padX) should be inset past the un-padded cx ($noPadX)") + end for + } + + // ---- an explicit linear x-domain is honored exactly (no nice-expansion) ---- + + "xScale linear with an explicit domain honors it exactly and does not nice-expand it" in { + // Data x spans 1..12; an explicit .xScale(_.linear(1.0, 12.0)) must resolve to [1,12]. + // The X path must honor the explicit domain with nice=false; passing nice=true uniformly would + // let fitLinear nice-expand [1,12] to [0,15] (data crammed into part of the plot). The Y path + // already honors an explicit linear domain with nice=false. + case class XRow(x: Double, y: Double) + val rows = Chunk.from((1 to 12).map(m => XRow(m.toDouble, m.toDouble))) + val spec = Chart(rows)(point(x = _.x, y = _.y)).xScale(_.linear(1.0, 12.0)) + spec.lowerWithScales.map { (_, sc) => + sc.x.kind match + case ScaleKind.Linear(lo, hi) => + assertClose(lo, 1.0, "explicit x-domain lo (must stay 1.0, not nice-expand to 0.0)") + assertClose(hi, 12.0, "explicit x-domain hi (must stay 12.0, not nice-expand to 15.0)") + case other => fail(s"Expected ScaleKind.Linear for explicit linear x but got $other") + end match + } + } + + // ---- a 7-category band x-axis yields 7 tick labels ---- + + "a 7-category band x-axis produces 7 tick labels" in { + case class CRow(cat: String, y: Int) + val cats = Chunk("a", "b", "c", "d", "e", "f", "g") + val rows = cats.map(c => CRow(c, 1)) + val spec = Chart(rows)(bar(x = _.cat, y = _.y)).xAxis(_.ticks(7)) + (spec).lower.map { root => + val labels = xTickLabelsIn(root) + assert(labels.size == 7, s"Expected 7 band tick labels but got ${labels.size}") + } + } + + // ---- chart-level linear clamp ---- + + "yScale linear withClamp(true) clamps an out-of-range datum to the range; withClamp(false) extrapolates" in { + // Fixed domain [0,10]; a datum y=20 is out of range. Bars: barY for the datum. + // clamp=true: y=20 maps to rangeHi (plot top, 20.0) -> barY = 20.0. + // clamp=false: y=20 extrapolates beyond the top -> barY < 20.0 (negative offset above the plot). + val rows = Chunk(Sale("Jan", Usd(20))) + val clamped = Chart(rows)(bar(x = _.month, y = _.revenue)).yScale(_.linear(0.0, 10.0).withClamp(true)) + val unclamped = Chart(rows)(bar(x = _.month, y = _.revenue)).yScale(_.linear(0.0, 10.0).withClamp(false)) + for + rootClamped <- (clamped).lower + rootUnclamped <- (unclamped).lower + yield + val yClamped = numOf(barsIn(rootClamped)(0).svgAttrs.y) + val yUnclamped = numOf(barsIn(rootUnclamped)(0).svgAttrs.y) + // Clamped pins to the top of the plot (PlotY=20). + assertClose(yClamped, PlotY, "clamped out-of-range datum pins to plot top") + // Unclamped extrapolates ABOVE the plot top, i.e. a smaller (more negative) pixel. + assert(yUnclamped < yClamped, s"Unclamped y ($yUnclamped) must extrapolate past the clamped top ($yClamped)") + end for + } + + // ---- chart-level symlog clamp ---- + + "yScale symlog withClamp(true) pins an out-of-domain datum to the boundary; withClamp(false) extrapolates" in { + // Symlog domain inferred from data [-5, 5]; add an out-of-domain datum 50. + case class SRow(x: String, y: Double) + val rows = Chunk(SRow("a", -5.0), SRow("b", 5.0)) + // Build a scale directly via Scale to compare clamp on/off at the same domain. + val s = Scale.Symlog(-5.0, 5.0, 0.0, 200.0, clamp = true) + val sOff = Scale.Symlog(-5.0, 5.0, 0.0, 200.0, clamp = false) + val atBoundary = s.apply(kyo.internal.Domain.Continuous(50.0)) + val atMax = s.apply(kyo.internal.Domain.Continuous(5.0)) + val extrap = sOff.apply(kyo.internal.Domain.Continuous(50.0)) + assertClose(atBoundary, atMax, "symlog clamp=true pins 50 to the domain max") + assert(extrap > atMax, s"symlog clamp=false ($extrap) must extrapolate past the domain max pixel ($atMax)") + } + + // ---- pad applied to an explicitly-overridden log scale ---- + + "yScale log withPad widens the log domain so the smallest datum is inset from the baseline" in { + // Data [10, 1000]; without pad, y=10 sits at the baseline. With pad, the log domain widens + // below 10, so y=10 is inset above the baseline. + val rows = Chunk(Sale("a", Usd(10)), Sale("b", Usd(1000))) + val noPad = Chart(rows)(bar(x = _.month, y = _.revenue)).yScale(_.log) + val padded = Chart(rows)(bar(x = _.month, y = _.revenue)).yScale(_.log.withPad(0.1)) + // The y pixel of the smallest datum (10): the bar bottom is at baseline; compare the bar TOP y. + for + rootNoPad <- (noPad).lower + rootPadded <- (padded).lower + yield + val yNoPad = numOf(barsIn(rootNoPad)(0).svgAttrs.y) + val yPadded = numOf(barsIn(rootPadded)(0).svgAttrs.y) + // Without pad the smallest datum maps to the baseline (440). With pad the domain widens below 10, + // so the datum maps ABOVE the baseline (a smaller y pixel). + assertClose(yNoPad, Baseline, "un-padded smallest log datum at baseline") + assert(yPadded < yNoPad, s"Padded log datum y ($yPadded) must be inset above the baseline ($yNoPad)") + end for + } + + // ---- Layout edge cases ---- + + /** All `Svg.Circle`s that are DIRECT children of root (frame chrome, e.g. size-legend sample bubbles), + * not the per-point data circles which live inside the marks group. + */ + private def frameCirclesIn(root: Svg.Root): Chunk[Svg.Circle] = + root.children.flatMap: + case c: Svg.Circle => Chunk(c) + case _ => Chunk.empty + + case class WideRow(month: String, revenue: Double, growthPct: Double) + given CanEqual[WideRow, WideRow] = CanEqual.derived + + // Wide left y-tick labels with a rotated left axis title must not clip at the left SVG edge. + // A 5-digit revenue domain forces a "50000"-class tick label; with a left axis title the + // left margin must grow so the leftmost tick label stays >= 0 and the plot is pushed right of the labels. + "wide 5-digit left y-tick labels + a left axis title do not clip at the SVG edge" in { + val rows = Chunk( + WideRow("Jan", 45000, 0.0), + WideRow("Jun", 83000, 18.6) + ) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .yAxis(_.label("Revenue")) + .size(360, 240) + (spec).lower.map { root => + // The left y-tick labels are right-anchored (TextAnchor.End). A 5-digit label "50000" must appear. + val leftTickLabels = frameTextsIn(root).filter(_.svgAttrs.textAnchor.contains(Svg.TextAnchor.End)) + val fiveDigit = leftTickLabels.filter: t => + t.children.headOption match + case Some(UI.Ast.Text(s)) => s.count(_.isDigit) >= 5 + case _ => false + assert(fiveDigit.nonEmpty, s"Expected a 5-digit left tick label; got ${leftTickLabels.flatMap(_.children)}") + + // The widest label is right-anchored at labelX; its leftmost rendered pixel is labelX - width. + // Assert it stays inside the SVG (>= 0) so the leading digit is not clipped. Width estimate: 7px/char. + val widest = fiveDigit.maxBy: t => + t.children.headOption match + case Some(UI.Ast.Text(s)) => s.length + case _ => 0 + val labelX = numOf(widest.svgAttrs.x) + val widthEstimate = + (widest.children.headOption match + case Some(UI.Ast.Text(s)) => s.length + case _ => 0 + ) * 7.0 + val leftmost = labelX - widthEstimate + assert(leftmost >= 0.0, s"Leftmost tick-label pixel ($leftmost) clips off the left edge (labelX=$labelX)") + + // The plot must be pushed right of the labels. The left axis SPINE is the vertical frame line with + // x1 == x2 and y1 < y2; its x is plotX. Pinning plotX at the default 60 would let the + // "50000" label, right-anchored at x=51, extend left to ~16 and collide with the rotated title at + // x=14, clipping the leading digit. plotX grows so the widest label clears the title column. + val spineXs = frameLinesIn(root).flatMap: l => + (l.svgAttrs.x1, l.svgAttrs.x2, l.svgAttrs.y1, l.svgAttrs.y2) match + case (Present(x1), Present(x2), Present(y1), Present(y2)) if math.abs(x1 - x2) < Tol && y1 < y2 => + Chunk(x1) + case _ => Chunk.empty + val plotX = if spineXs.isEmpty then 0.0 else spineXs.min + assert(plotX >= 70.0, s"Left margin did not grow for wide labels: plotX=$plotX (default 60)") + // The tick label likewise sits right of the default x=51, at plotX - TickLen - 4. + assert(labelX > 51.0, s"Tick label x ($labelX) did not move right of the default 51") + } + } + + case class SizeRow(a: Double, b: Double, w: Double) + given CanEqual[SizeRow, SizeRow] = CanEqual.derived + + // A point chart with a size encoding must render its size legend as sample circles OUTSIDE the plot data + // area, not floating over a data bubble. The plot is shifted down to reserve a top legend strip; the + // sample bubbles sit entirely above plotY. + "size-legend sample circles render outside the plot data area, above plotY" in { + val rows = Chunk( + SizeRow(1.2, 3.4, 8.0), + SizeRow(5.0, 5.5, 15.0), + SizeRow(8.2, 4.0, 6.0) + ) + val spec = Chart(rows)(point(x = _.a, y = _.b, size = _.w)) + .yAxis(_.grid) + .size(360, 240) + (spec).lower.map { root => + // plotY is where the left axis line starts (its top y). With the top strip reserved it is 40, not 20. + val ys1 = frameLinesIn(root).map(_.svgAttrs.y1.getOrElse(0.0)).filter(_ > 0.0) + val plotY = if ys1.isEmpty then 0.0 else ys1.min + assert(plotY >= 40.0, s"Plot was not shifted down to reserve the size-legend strip: plotY=$plotY") + + // The size legend emits two translucent (fillOpacity 0.5) sample circles in frame chrome. + val sampleCircles = frameCirclesIn(root).filter(_.svgAttrs.fillOpacity.contains(0.5)) + assert(sampleCircles.size == 2, s"Expected 2 size-legend sample circles, got ${sampleCircles.size}") + + // Each sample circle's full extent (center +/- radius) must sit ABOVE the plot data area (cy + r <= plotY), + // so it never overlaps a plotted point. + sampleCircles.foldLeft(()): (_, c) => + val cy = c.svgAttrs.cy.getOrElse(0.0) + val r = c.svgAttrs.r.getOrElse(0.0) + assert(cy + r <= plotY, s"Size-legend bubble (cy=$cy r=$r) dips into the plot data area (plotY=$plotY)") + } + } + + case class ComboRow(month: String, revenue: Double, growthPct: Double) + given CanEqual[ComboRow, ComboRow] = CanEqual.derived + + // GUARD: a bar+line combo lists bar THEN line, so spec order must place the line path AFTER all bar rects + // in the SVG so the line draws ON TOP of the bars. This was reported as a possible z-order issue; it is + // correct already, and this test guards that the spec-order layering holds. + "bar+line combo emits the line path after all bar rects (z-order guard)" in { + val rows = Chunk( + ComboRow("Jan", 45000, 0.0), + ComboRow("Feb", 52000, 15.6), + ComboRow("Mar", 48000, -7.7) + ) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ) + .yAxis(_.label("Revenue")) + .yAxisRight(_.label("Growth %")) + .size(360, 240) + for + root <- (spec).lower + html <- kyo.internal.HtmlRenderer.render(root, Seq.empty) + yield + val lastRect = html.lastIndexOf("= 0, "expected at least one bar ") + assert(firstPath >= 0, "expected a line ") + assert(firstPath > lastRect, s"line path (idx $firstPath) must come after the last bar rect (idx $lastRect)") + end for + } + + // ---- Dual-axis and font helpers ---- + + /** Left y-axis tick labels: frame texts with TextAnchor.End (left of plotX). */ + private def leftYTickLabelsIn(root: Svg.Root): Chunk[Svg.Text] = + frameTextsIn(root).filter(_.svgAttrs.textAnchor.contains(Svg.TextAnchor.End)) + + /** Right y-axis tick labels: frame texts with TextAnchor.Start to the right of plot area. */ + private def rightYTickLabelsIn(root: Svg.Root): Chunk[Svg.Text] = + frameTextsIn(root).filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.Start) && + t.svgAttrs.x.exists { case Coord.Num(v) => v > PlotX + PlotWTwoAx; case _ => false } + + // ---- yAxis rotateTicks gives every Y tick a Rotate transform ---- + + "yAxis(_.rotateTicks(-45)) gives every Y tick label a Rotate(-45) transform" in { + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)).yAxis(_.rotateTicks(-45.0)) + (spec).lower.map { root => + val ticks = leftYTickLabelsIn(root) + assert(ticks.nonEmpty, "Expected left Y tick labels") + ticks.foldLeft(()): (_, t) => + val rot = t.svgAttrs.transform.toSeq.collectFirst { case r: Svg.Transform.Rotate => r } + rot match + case Some(r) => assertClose(r.deg, -45.0, "Y tick label rotate degrees") + case None => fail(s"Expected a Rotate transform on Y tick label but got ${t.svgAttrs.transform}") + } + } + + "yAxisRight(_.rotateTicks(30)) gives every right Y tick label a Rotate(30) transform" in { + // Both left and right Y axes go through buildYAxis. + val rows = Chunk(Row2Ax("Jan", Usd(1000), 5.0), Row2Ax("Feb", Usd(2000), 10.0)) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ).yAxisRight(_.rotateTicks(30.0)) + (spec).lower.map { root => + val ticks = rightYTickLabelsIn(root) + assert(ticks.nonEmpty, "Expected right Y tick labels") + ticks.foldLeft(()): (_, t) => + val rot = t.svgAttrs.transform.toSeq.collectFirst { case r: Svg.Transform.Rotate => r } + rot match + case Some(r) => assertClose(r.deg, 30.0, "Right Y tick label rotate degrees") + case None => fail(s"Expected a Rotate transform on right Y tick label but got ${t.svgAttrs.transform}") + } + } + + // ---- anchor sets Y tick text-anchor; side-default preserved when unset ---- + + "yAxis(_.anchor(TextAnchor.Start)) sets text-anchor=start on left Y tick labels" in { + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)).yAxis(_.anchor(TextAnchor.Start)) + (spec).lower.map { root => + // Left Y ticks carry Start anchor (not filtered by TextAnchor.End). + // Use dominantBaseline.Middle to isolate Y ticks (not Hanging=X, not absent=rotated-title). + val ticks = frameTextsIn(root).filter(_.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Middle)) + assert(ticks.nonEmpty, "Expected Y tick labels with DominantBaseline.Middle") + ticks.foldLeft(()): (_, t) => + assert( + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.Start), + s"Y tick with explicit anchor(Start) must have text-anchor=start but got ${t.svgAttrs.textAnchor}" + ) + } + } + + "left Y tick label keeps text-anchor=end when no anchor is set (byte-identity)" in { + // Side-default anchor (End for left, Start for right) must be preserved when cfg.tickAnchor is + // the default TextAnchor.Middle. This is the byte-identity guard for the no-anchor case. + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + (spec).lower.map { root => + val ticks = leftYTickLabelsIn(root) + assert(ticks.nonEmpty, "Expected left Y tick labels") + ticks.foldLeft(()): (_, t) => + assert( + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End), + s"Default left Y tick must retain text-anchor=end (side-default) but got ${t.svgAttrs.textAnchor}" + ) + } + } + + // ---- theme font applied to Y ticks, axis titles, legend text ---- + + "theme font appears on Y tick, axis title, and legend label" in { + val rows = Chunk( + Sale("Jan", Usd(1000), Region.NA), + Sale("Feb", Usd(2000), Region.EU) + ) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue, color = _.region) + ) + .yAxis(_.label("Revenue")) + .theme(_.font("monospace").fontSize(14)) + .legend(_.colorScale[Region](Region.NA -> Style.Color.blue, Region.EU -> Style.Color.green)) + (spec).lower.map { root => + // A Y tick label: DominantBaseline.Middle + TextAnchor.End (left Y, default config). + val yTick = frameTextsIn(root) + .find(t => + t.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Middle) && + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End) + ) + .getOrElse(fail("Expected a left Y tick label with DominantBaseline.Middle + TextAnchor.End")) + assert( + yTick.svgAttrs.fontFamily.contains("monospace"), + s"Y tick label must carry font-family=monospace but got ${yTick.svgAttrs.fontFamily}" + ) + assert( + yTick.svgAttrs.fontSize.exists(_.toString.contains("14")), + s"Y tick label must carry font-size=14px but got ${yTick.svgAttrs.fontSize}" + ) + + // An axis title: the rotated y-title has a Rotate transform. + val yTitle = frameTextsIn(root) + .find(t => t.svgAttrs.transform.toSeq.exists { case _: Svg.Transform.Rotate => true; case _ => false }) + .getOrElse(fail("Expected a rotated axis-title text")) + assert( + yTitle.svgAttrs.fontFamily.contains("monospace"), + s"Y axis title must carry font-family=monospace but got ${yTitle.svgAttrs.fontFamily}" + ) + + // A legend label: DominantBaseline.Middle, not End (legend labels are not anchored End in + // a categorical legend with default Top position), and no Rotate transform. + val legendLabel = frameTextsIn(root) + .find(t => + t.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Middle) && + !t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End) && + !t.svgAttrs.textAnchor.contains(Svg.TextAnchor.Start) && + t.svgAttrs.transform.isEmpty + ) + .getOrElse(fail("Expected a legend label text without End/Start anchor and without Rotate")) + assert( + legendLabel.svgAttrs.fontFamily.contains("monospace"), + s"Legend label must carry font-family=monospace but got ${legendLabel.svgAttrs.fontFamily}" + ) + } + } + + "default theme adds no font-family or font-size to Y tick, title, or legend (byte-identity)" in { + // withFont is a no-op when theme.fontFamily and theme.fontSize are both Absent (the default). + // No font attr must appear on any frame text when no theme font is set. + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + (spec).lower.map { root => + frameTextsIn(root).foldLeft(()): (_, t) => + assert(t.svgAttrs.fontFamily.isEmpty, s"Default theme must NOT add font-family; got ${t.svgAttrs.fontFamily}") + assert(t.svgAttrs.fontSize.isEmpty, s"Default theme must NOT add font-size; got ${t.svgAttrs.fontSize}") + } + } + + // ---- x tick-label chrome resolved through the shared helper ---- + + "x tick rotateTicks/anchor/font are applied through the shared helper" in { + // buildXAxis adds withFont to the title block; the tickLabel helper applies rotation, anchor, + // and font to each x tick label. + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + + val rotSpec = Chart(rows)(bar(x = _.month, y = _.revenue)).xAxis(_.rotateTicks(-45.0)) + val ancSpec = Chart(rows)(bar(x = _.month, y = _.revenue)).xAxis(_.anchor(TextAnchor.End)) + val fntSpec = Chart(rows)(bar(x = _.month, y = _.revenue)).theme(_.font("monospace").fontSize(14)) + for + rotRoot <- (rotSpec).lower + ancRoot <- (ancSpec).lower + fntRoot <- (fntSpec).lower + yield + // Rotation (mirrors the rotateTicks test at line 972): + val xTicks = xTickLabelsIn(rotRoot) + assert(xTicks.nonEmpty, "Expected x tick labels") + xTicks.foldLeft(()): (_, t) => + val rot = t.svgAttrs.transform.toSeq.collectFirst { case r: Svg.Transform.Rotate => r } + rot match + case Some(r) => assertClose(r.deg, -45.0, "x tick rotate") + case None => fail(s"Expected Rotate on x tick but got ${t.svgAttrs.transform}") + + // Anchor (mirrors the anchor test at line 987): + xTickLabelsIn(ancRoot).foldLeft(()): (_, t) => + assert(t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End), "x tick anchor") + + // Font: + xTickLabelsIn(fntRoot).foldLeft(()): (_, t) => + assert(t.svgAttrs.fontFamily.contains("monospace"), "x tick font-family") + assert(t.svgAttrs.fontSize.exists(_.toString.contains("14")), "x tick font-size") + end for + } + + // ---- Right-axis helpers ---- + + /** Horizontal gridlines: Lines spanning plotX to plotX+plotW with strokeOpacity set. */ + private def hGridLinesIn(root: Svg.Root, plotW: Double = PlotW): Chunk[Svg.Line] = + frameLinesIn(root).filter: l => + l.svgAttrs.x1.exists(_ == PlotX) && + l.svgAttrs.x2.exists(_ == PlotX + plotW) && + l.svgAttrs.y1 == l.svgAttrs.y2 && + l.svgAttrs.strokeOpacity.isDefined + + // ---- right-bound datum pixel matches log scale ---- + + "yScaleRight(_.log) projects a right-bound datum at the log-scaled pixel, not linear" in { + // Right axis: data=[1.0, 100.0], log scale. + // Log scale: domain [1.0, 100.0], range [440, 20]. + // apply(1.0) = rangeLo = 440.0 (domain min maps to rangeLo for log scale) + // apply(100.0) = rangeHi = 20.0 (domain max maps to rangeHi) + // Linear scale (old behavior): domain nice(0,100)=[0,100], range [440, 20]. + // apply(1.0) = 440 + (1.0/100) * (20-440) = 440 - 4.2 = 435.8 + // Log vs linear differ by ~4px at growthPct=1.0, discriminating the scale kind. + val rows = Chunk(Row2Ax("Jan", Usd(1000), 1.0), Row2Ax("Feb", Usd(2000), 100.0)) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ).yScaleRight(_.log) + (spec).lower.map { root => + // Extract the right-bound line path from the marks G (last child of root). + val marksG: Svg.G = root.children.last match + case g: Svg.G => g + case other => fail(s"Expected marks G as last child but got $other") + // Gather all elements (line paths live inside nested G elements). + val paths = marksG.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case p: Svg.Path => Chunk(p) + case _ => Chunk.empty + case p: Svg.Path => Chunk(p) + case _ => Chunk.empty + + // Extract all y-pixel values from path commands (MoveTo/LineTo y coordinates). + val allYPx: Chunk[Double] = paths.flatMap: p => + p.svgAttrs.d match + case Present(pd) => + PathData.commands(pd).flatMap: + case PathCommand.MoveTo(_, y) => Chunk(y) + case PathCommand.LineTo(_, y) => Chunk(y) + case _ => Chunk.empty + case Absent => Chunk.empty + + assert(allYPx.nonEmpty, s"Expected Y pixel values in line path commands but got none") + // With log scale: apply(1.0)=440.0 (domain min -> rangeLo). + // With linear scale: apply(1.0)≈435.8 (linear interpolation). + // The key discriminator: does any y exactly match 440.0 (log for growthPct=1.0)? + val hasLogBaseline = allYPx.exists(y => math.abs(y - 440.0) < 2.0) + assert(hasLogBaseline, s"Expected log-scaled y near 440.0 for growthPct=1.0 but got: $allYPx") + + // Confirm the linear fallback value is NOT present (linear would put 1.0 at ~435.8). + val hasLinearFallback = allYPx.exists(y => y > 433.0 && y < 438.0) + assert(!hasLinearFallback, s"Found linear-scaled y near 435-438 (expected log scaling): $allYPx") + } + } + + // ---- existing dual-axis test pixel unchanged (byte-identity) ---- + + "dual-axis chart with no yScaleRight uses default Linear+nice right scale, right ticks exist (byte-identity)" in { + // Right: growthPct=[10.0, 20.0]; yRightExtent = Continuous(10, 20). + // niceTicks(10,20,5): step=5 -> snapped domain=[10,20]. + // Linear(10, 20, rangeLo=440, rangeHi=20, nice=true): + // apply(10) = 440 (domain min -> rangeLo=440) + // apply(15) = 440 + (15-10)/(20-10) * (20-440) = 440 + 0.5*(-420) = 230 + // apply(20) = 20 (domain max -> rangeHi=20) + // This byte-identity check: the default (no yScaleRight) produces the same + // Linear+nice scale as the hardcoded Scale.fit(Linear, rExt, plotBaseline, plotY, nice=true). + val rows = Chunk( + Row2Ax("Jan", Usd(1000), 10.0), + Row2Ax("Feb", Usd(2000), 20.0) + ) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ) + (spec).lower.map { root => + // Right axis tick labels appear on the right margin. + val allTexts = frameTextsIn(root) + val rightTickLabels = allTexts.filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.Start) && + t.svgAttrs.x.exists { case Coord.Num(v) => v > PlotX + PlotWTwoAx; case _ => false } + assert(rightTickLabels.nonEmpty, "Expected right-axis tick labels (byte-identity)") + + // The tick labeled "10" (domain min) should be at y=440 (rangeLo=plotBaseline). + // niceTicks(10, 20, 5): step=5 -> ticks [10, 15, 20] (3 ticks); "10" is always present. + val tick10 = rightTickLabels.find(t => t.children.exists { case UI.Ast.Text("10") => true; case _ => false }) + tick10 match + case Some(t) => + val py = numOf(t.svgAttrs.y) + assertClose(py, 440.0, "right scale tick '10' (domain min) should be at y=440 (plotBaseline)") + case None => + fail(s"tick '10' not found in right tick labels: ${rightTickLabels.map(_.children).toList}") + end match + + // The tick labeled "20" (domain max) should be at y=20 (rangeHi=plotY). + // niceTicks(10, 20, 5): "20" is the domain max and always present as the last tick. + val tick20 = rightTickLabels.find(t => t.children.exists { case UI.Ast.Text("20") => true; case _ => false }) + tick20 match + case Some(t) => + val py = numOf(t.svgAttrs.y) + assertClose(py, 20.0, "right scale tick '20' (domain max) should be at y=20 (plotY)") + case None => + fail(s"tick '20' not found in right tick labels: ${rightTickLabels.map(_.children).toList}") + end match + } + } + + // ---- right gridlines emitted; left-wins guard ---- + + ".yAxisRight(_.grid) with left grid OFF emits right horizontal gridlines" in { + val rows = Chunk(Row2Ax("Jan", Usd(1000), 5.0), Row2Ax("Feb", Usd(2000), 20.0)) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ).yAxisRight(_.grid) + // Left grid is NOT set; right grid is set -> rightDrawGrid=true, leftDrawGrid=false. + (spec).lower.map { root => + // Horizontal gridlines span x1=plotX to x2=plotX+plotW_twoax with strokeOpacity set. + val gridLines = hGridLinesIn(root, PlotWTwoAx) + // Right axis domain: growthPct in [5.0, 20.0], default tickCount=5, nice=true. + // niceTicks(5.0, 20.0, 5): step=5 -> ticks [5, 10, 15, 20] = 4 ticks. + // One gridline per right tick -> 4 gridlines. + assert( + gridLines.size == 4, + s"Expected 4 right horizontal gridlines (one per right tick: 5, 10, 15, 20) but got ${gridLines.size}" + ) + // Gridline y-positions must match the right scale tick pixels. + // Right scale: Linear(5.0, 20.0, rangeLo=440, rangeHi=20): apply(v) = 440 + (v-5)/(20-5)*(20-440). + val expectedYs = Chunk(440.0, 300.0, 160.0, 20.0) // apply(5), apply(10), apply(15), apply(20) + val actualYs = gridLines.flatMap(l => l.svgAttrs.y1.map(_.toDouble)).sorted.reverse + expectedYs.zip(actualYs).foldLeft(()): (_, pair) => + val (expected, actual) = pair + assertClose(actual, expected, s"gridline y-position mismatch (expected $expected, got $actual)") + } + } + + "L13b: .yAxis(_.grid) + .yAxisRight(_.grid) emits only LEFT tick count gridlines (left-wins guard)" in { + // When both left and right have showGrid=true, leftDrawGrid wins. + // Left: revenue=[0,2000], niceTicks(0,2000,5)=[0,500,1000,1500,2000] -> 5 ticks. + // Right grid suppressed by leftDrawGrid=true. Total gridlines == left tick count. + val rows = Chunk(Row2Ax("Jan", Usd(1000), 5.0), Row2Ax("Feb", Usd(2000), 20.0)) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ).yAxis(_.grid).yAxisRight(_.grid) + (spec).lower.map { root => + val gridLines = hGridLinesIn(root, PlotWTwoAx) + assert(gridLines.nonEmpty, "Expected gridlines when yAxis(_.grid) is set") + // All gridlines come from the left; count matches left tick count. + // niceTicks(0,2000,5)=[0,500,1000,1500,2000] -> 5 ticks. gridLines.size should == 5. + assert( + gridLines.size == 5, + s"Expected exactly 5 gridlines (left tick count) but got ${gridLines.size} (right grid should be suppressed by left-wins)" + ) + } + } + + // ---- yScale(_.log) + yScaleRight(_.linear) independence ---- + + ".yScale(_.log).yScaleRight(_.linear(0,1)) leaves left log and right linear (independence)" in { + // Left: bar revenue=[1000,2000], log scale. + // domain no-zero [1000,2000], range [440, 20+topHeadroom]. + // apply(1000) = baseline = 440 (log bottom = domain min maps to rangeLo). + // apply(2000) = top (log top = domain max maps to rangeHi). + // Right: line growthPct=[0.1, 0.9], linear(0,1). + // apply(0.5) = 440 + 0.5 * (20-440) = 230.0 + // Verify independence: left is log (bottom of data at baseline), right is linear clamped [0,1]. + val rows = Chunk(Row2Ax("Jan", Usd(1000), 0.1), Row2Ax("Feb", Usd(2000), 0.9)) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growthPct, axis = Axis.Right) + ).yScale(_.log).yScaleRight(_.linear(0.0, 1.0)) + (spec).lower.map { root => + // Both axes should render tick labels (confirming both exist with different scale kinds). + val allTexts = frameTextsIn(root) + val leftTicks = allTexts.filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End) && + t.svgAttrs.x.exists { case Coord.Num(v) => v < PlotX; case _ => false } + val rightTicks = allTexts.filter: t => + t.svgAttrs.textAnchor.contains(Svg.TextAnchor.Start) && + t.svgAttrs.x.exists { case Coord.Num(v) => v > PlotX + PlotWTwoAx; case _ => false } + + assert(leftTicks.nonEmpty, "Expected left-axis tick labels") + assert(rightTicks.nonEmpty, "Expected right-axis tick labels") + + // Right scale is linear(0,1), domain fixed [0,1], nice=false, tickCount=5. + // niceTicks(0.0, 1.0, 5): step=0.5 -> ticks [0.0, 0.5, 1.0] (3 ticks); "0.5" is always present. + // apply(0.5) = 440 + 0.5 * (20-440) = 440 - 210 = 230.0. + val tick05 = rightTicks.find(t => t.children.exists { case UI.Ast.Text("0.5") => true; case _ => false }) + tick05 match + case Some(t) => + val py = numOf(t.svgAttrs.y) + assertClose(py, 230.0, "right linear(0,1) tick at 0.5 should be at y=230") + case None => + fail(s"tick '0.5' not found in right tick labels: ${rightTicks.map(_.children).toList}") + end match + } + } + +end ChartAxisTest diff --git a/kyo-ui/shared/src/test/scala/kyo/ChartFoundationsTest.scala b/kyo-ui/shared/src/test/scala/kyo/ChartFoundationsTest.scala new file mode 100644 index 0000000000..cc1e745383 --- /dev/null +++ b/kyo-ui/shared/src/test/scala/kyo/ChartFoundationsTest.scala @@ -0,0 +1,126 @@ +package kyo + +import kyo.Chart.* +import kyo.UI.* +import kyo.UI.Ast.* +import kyo.internal.ChartFoundations +import kyo.internal.ChartFoundations.CatKey +import kyo.internal.Domain +import kyo.internal.HtmlRenderer + +class ChartFoundationsTest extends kyo.test.Test[Any]: + + // Two enum cases that override toString to the same label: they collide under toString-keyed + // dedup but stay distinct under CatKey (keyed by tag + value, the case being its own value). + enum Col derives CanEqual: + case Red + case Blue + override def toString: String = "color" + end Col + + // ---- CatKey identity by stable type tag + value, not toString, cross-platform ---- + + "catKey distinguishes Int 1 from String 1 despite equal toString" in { + val k1 = ChartFoundations.categoryKey(1) + val k2 = ChartFoundations.categoryKey("1") + assert(k1 != k2, "Int 1 and String \"1\" must have distinct keys despite equal toString") + } + + "catKey is stable: the same value keys to an equal CatKey" in { + assert(ChartFoundations.categoryKey(1) == ChartFoundations.categoryKey(1), "Int 1 must key stably") + assert(ChartFoundations.categoryKey("a") == ChartFoundations.categoryKey("a"), "String a must key stably") + } + + "catKey distinguishes distinct values of the same type" in { + assert(ChartFoundations.categoryKey(1) != ChartFoundations.categoryKey(2), "Int 1 and 2 must differ") + } + + // ---- enum cases with colliding toString stay distinct (keyed by tag + value) ---- + + "catKey distinguishes enum cases that share a toString" in { + // Col.Red and Col.Blue both override toString to "color"; CatKey keys by tag + value + // (the enum case is its own value), so they stay distinct despite the toString collision. + val red = ChartFoundations.categoryKey(Col.Red) + val blue = ChartFoundations.categoryKey(Col.Blue) + assert(red != blue, "Col.Red and Col.Blue must have distinct keys despite equal toString") + assert(red == ChartFoundations.categoryKey(Col.Red), "Col.Red must key stably") + } + + // ---- a typed null VALUE flowing through the explicit-tag form is null-safe ---- + + "catKey keys a typed null value via the explicit-tag form without NPE" in { + // A typed encoding accessor (e.g. Encoding[A, String]) can yield a null reference. That flows + // through categoryKey(tag, value) where value: Any may be null. The key must be stable and + // distinct from a non-null value under the same tag, and must never throw. + val t = summon[ConcreteTag[String]] + assert( + ChartFoundations.categoryKey(t, null) == ChartFoundations.categoryKey(t, null), + "Two null values under the same tag must key equal" + ) + assert( + ChartFoundations.categoryKey(t, "x") != ChartFoundations.categoryKey(t, null), + "A null value must key distinctly from a non-null value under the same tag" + ) + } + + // ---- distinctKeyed preserves first-seen order and deduplicates by key ---- + + "distinctKeyed deduplicates by CatKey in encounter order" in { + val rows = Chunk("a", "b", "a", "c") + val result = ChartFoundations.distinctKeyed(rows, r => ChartFoundations.categoryKey(r)) + assert(result.size == 3, s"Expected 3 distinct entries but got ${result.size}") + assert(result(0)._2 == "a", s"First entry should be 'a' but got ${result(0)._2}") + assert(result(1)._2 == "b", s"Second entry should be 'b' but got ${result(1)._2}") + assert(result(2)._2 == "c", s"Third entry should be 'c' but got ${result(2)._2}") + } + + "distinctKeyed empty fast-path returns Chunk.empty" in { + val result = ChartFoundations.distinctKeyed(Chunk.empty[String], r => ChartFoundations.categoryKey(r)) + assert(result.isEmpty, "Empty input must return Chunk.empty") + } + + // ---- chartIdPrefix is content-stable and distinct ---- + + "chartIdPrefix is deterministic for the same spec object and distinct for different specs" in { + case class Row(x: Int, y: Double) + given CanEqual[Row, Row] = CanEqual.derived + val rows = Chunk(Row(1, 2.0)) + val spec1 = Chart(rows)(bar(x = _.x, y = _.y)) + val spec3 = Chart(rows)(bar(x = _.x, y = _.y), point(x = _.x, y = _.y)) + // Same spec object called twice: must produce the same prefix (deterministic hashing) + val p1a = ChartFoundations.chartIdPrefix(spec1) + val p1b = ChartFoundations.chartIdPrefix(spec1) + assert(p1a == p1b, s"Same spec must produce same prefix both times: p1a=$p1a p1b=$p1b") + // Different spec with different mark count: must produce a different prefix + val p3 = ChartFoundations.chartIdPrefix(spec3) + assert(p1a != p3, s"Different specs must produce different prefix: p1a=$p1a p3=$p3") + assert(p1a.startsWith("kyo-chart-"), s"Prefix must start with kyo-chart- but got $p1a") + } + + // ---- filterFinite drops NaN/Inf and the finite extent of {1.0, NaN, 3.0} is exactly [1.0, 3.0] ---- + + "filterFinite retains finite values and the fitted extent for {1.0, NaN, 3.0} is exactly [1.0, 3.0]" in { + case class Row(x: Int, y: Double) + val rows = Chunk(Row(0, 1.0), Row(1, Double.NaN), Row(2, 3.0)) + val spec = Chart(rows)(bar(x = _.x, y = _.y)) + // NaN-free: the full render pipeline must not emit "NaN" or "Infinity" + for + root <- (spec).lower + html <- HtmlRenderer.render(root, Seq.empty) + yield + assert(!html.contains("NaN"), s"SVG output must not contain 'NaN': ${html.take(200)}") + assert(!html.contains("Infinity"), s"SVG output must not contain 'Infinity'") + // Exact-extent: filterFinite drops Double.NaN, leaving {1.0, 3.0}. + // Verify the finite guard on each domain value directly. + val domainValues = rows.map(r => ChartFoundations.filterFinite(Present(Domain.Continuous(r.y)))) + val finite = domainValues.filter(_.isDefined) + val finiteDoubles = finite.collect { case Present(Domain.Continuous(v)) => v } + assert(finiteDoubles == Chunk(1.0, 3.0), s"filterFinite must drop NaN and retain {1.0, 3.0}: $finiteDoubles") + val lo = finiteDoubles.foldLeft(Double.MaxValue)(math.min) + val hi = finiteDoubles.foldLeft(Double.MinValue)(math.max) + assert(lo == 1.0, s"Inferred extent lo must be 1.0: $lo") + assert(hi == 3.0, s"Inferred extent hi must be 3.0: $hi") + end for + } + +end ChartFoundationsTest diff --git a/kyo-ui/shared/src/test/scala/kyo/ChartInteractionTest.scala b/kyo-ui/shared/src/test/scala/kyo/ChartInteractionTest.scala new file mode 100644 index 0000000000..60f14c7d4a --- /dev/null +++ b/kyo-ui/shared/src/test/scala/kyo/ChartInteractionTest.scala @@ -0,0 +1,960 @@ +package kyo + +import kyo.Chart.* +import kyo.UI.* +import kyo.UI.Ast.* +import kyo.UI.Ast.Reactive +import kyo.internal.HtmlRenderer +import scala.language.implicitConversions + +/** Tests for hover/select handlers, tooltip overlay, reactive rule, and linked views. + * + * Layout defaults: plotX=60, plotY=20, plotW=560, plotH=420, baseline=440 + * (chart size 640x480, MarginL=60, MarginR=20, MarginT=20, MarginB=40). + * + * Scale used in geometry assertions: yScale(_.linear(0, 4000)), nice=false. + * pixel(v) = 440 + (v / 4000) * (20 - 440) = 440 - v * 0.105 + * barH(v) = 440 - pixel(v) = v * 0.105 + * + * The six tests cover: + * 1. Mark shapes carry Present onHover and onClick attrs when the chart has onHover/onSelect configured. + * 2. Driving the hover handler sets the user SignalRef to Present(row); unhover sets Absent. + * 3. onSelect: running the click handler sets the select ref to the clicked row. + * 4. tooltip(f): after simulated hover, the overlay Reactive g renders f(row) text. + * 5. Reactive rule: rule(y = signal) lowers inside a Reactive Svg.G; a new signal value moves the + * rule's scaled y. + * 6. Linked views: rule(x = hovered.map(...)) follows the published hover signal. + */ +class ChartInteractionTest extends kyo.test.Test[Any]: + + // ---- shared domain types ---- + + opaque type Rev <: Double = Double + object Rev: + def apply(d: Double): Rev = d + given Plottable[Rev] = Plottable.numeric + given CanEqual[Rev, Rev] = CanEqual.derived + implicit def doubleToRev(d: Double): Rev = d + end Rev + + case class Sale(month: String, revenue: Rev) + given CanEqual[Sale, Sale] = CanEqual.derived + + // ---- layout constants (must match ChartLower) ---- + private val PlotX = 60.0 + private val PlotY = 20.0 + private val PlotW = 560.0 + private val PlotH = 420.0 + private val Baseline = PlotY + PlotH // 440.0 + + // ---- helpers ---- + + /** Collect all Svg.Rect children from a root, traversing one level of Svg.G. */ + private def rectsIn(root: Svg.Root): Chunk[Svg.Rect] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case r: Svg.Rect => Chunk(r) + case ig: Svg.G => + ig.children.collect { case r: Svg.Rect => r } + case _ => Chunk.empty + case _ => Chunk.empty + + /** Collect all Svg.Circle children. */ + private def circlesIn(root: Svg.Root): Chunk[Svg.Circle] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case c: Svg.Circle => Chunk(c) + case ig: Svg.G => ig.children.collect { case c: Svg.Circle => c } + case _ => Chunk.empty + case _ => Chunk.empty + + /** Collect all Reactive children from a root at the top level. */ + private def reactivesIn(root: Svg.Root): Chunk[Reactive[?]] = + root.children.collect: + case r: Reactive[?] => r + + /** Run an `Any < Async` action as a test effect and return Unit. */ + private def runAction(action: Any < Async)(using Frame): Unit < Async = + action.map(_ => ()) + + // ---- Test 1: each mark shape carries Present onHover and onClick handlers ---- + + "each mark shape carries Present onHover and onClick handlers when onHover/onSelect are configured" in { + for + hoverRef <- Signal.initRef[Maybe[Sale]](Absent) + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .onHover(hoverRef) + .onSelect(selectRef) + root <- (spec).lower + rects = rectsIn(root) + yield + // Both bars should be present. + assert(rects.size == 2, s"Expected 2 rects but got ${rects.size}") + // Each rect carries onHover and onClick; assert them individually (foreach returns Unit, not Assertion). + val r0 = rects(0) + val r1 = rects(1) + assert(r0.attrs.onHover.isDefined, "rect[0]: Expected Present onHover") + assert(r0.attrs.onClick.isDefined, "rect[0]: Expected Present onClick") + assert(r1.attrs.onHover.isDefined, "rect[1]: Expected Present onHover") + assert(r1.attrs.onClick.isDefined, "rect[1]: Expected Present onClick") + } + + // ---- Test 2: hover/unhover handlers set and clear the user SignalRef ---- + + "hover handler sets onHover SignalRef to Present(row); unhover sets Absent" in { + for + hoverRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0))) + spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .onHover(hoverRef) + root <- (spec).lower + rects = rectsIn(root) + _ = assert(rects.size == 1, s"Expected 1 rect but got ${rects.size}") + rect = rects(0) + // onHover is Present; run the action. + hoverAction = rect.attrs.onHover + _ = assert(hoverAction.isDefined, "Expected Present onHover on rect") + _ <- runAction(hoverAction.get) + afterHover <- hoverRef.get + // onUnhover is Present; run it. + unhoverAction = rect.attrs.onUnhover + _ = assert(unhoverAction.isDefined, "Expected Present onUnhover on rect") + _ <- runAction(unhoverAction.get) + afterUnhover <- hoverRef.get + yield + // After hover, ref should be Present(Sale("Jan", Rev(1000.0))). + assert( + afterHover == Present(Sale("Jan", Rev(1000.0))), + s"Expected Present(Sale(Jan, 1000)) after hover but got $afterHover" + ) + // After unhover, ref should be Absent. + assert( + afterUnhover == Absent, + s"Expected Absent after unhover but got $afterUnhover" + ) + } + + // ---- Test 3: onClick sets onSelect SignalRef ---- + + "onClick handler sets onSelect SignalRef to the clicked row" in { + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Mar", Rev(3000.0)), Sale("Apr", Rev(4000.0))) + spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .onSelect(selectRef) + root <- (spec).lower + rects = rectsIn(root) + _ = assert(rects.size == 2, s"Expected 2 rects but got ${rects.size}") + // Click the second rect (Apr, Rev(4000)). + clickAction = rects(1).attrs.onClick + _ = assert(clickAction.isDefined, "Expected Present onClick on second rect") + _ <- runAction(clickAction.get) + afterClick <- selectRef.get + yield assert( + afterClick == Present(Sale("Apr", Rev(4000.0))), + s"Expected Present(Sale(Apr, 4000)) after click but got $afterClick" + ) + } + + // ---- Test 4: tooltip(f) renders overlay text after simulated hover ---- + + "tooltip(f) renders f(row) in the overlay Reactive after simulated hover" in { + for + rows = Chunk(Sale("May", Rev(500.0))) + spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .tooltip(s => s"${s.month}: ${s.revenue.toInt}") + root <- (spec).lower + // Locate the tooltip overlay: last Reactive child of root. + topReactives = reactivesIn(root) + _ = assert(topReactives.nonEmpty, "Expected at least one Reactive child (tooltip overlay)") + // Locate the bar rect and its onHover handler. + rects = rectsIn(root) + _ = assert(rects.size == 1, s"Expected 1 rect but got ${rects.size}") + rect = rects(0) + _ = assert(rect.attrs.onHover.isDefined, "Expected Present onHover on rect for tooltip") + // Simulate hover: set the internal hover ref. + _ <- runAction(rect.attrs.onHover.get) + // Render the root; the tooltip overlay should now contain the formatted text. + html <- HtmlRenderer.render(root, Seq.empty) + yield + // The rendered HTML should contain the exact formatted tooltip text. + // Using .toInt avoids platform-dependent Double.toString ("500.0" on JVM, "500" on JS). + assert( + html.contains("May: 500"), + s"Expected tooltip text 'May: 500' in rendered HTML but got:\n${html.take(2000)}" + ) + } + + // ---- Test 5: reactive rule lowers inside Reactive; new signal value moves scaled y ---- + + "rule(y = signal) lowers inside a Reactive; a new threshold moves the rule's scaled y" in { + // Scale: linear(0, 4000), baseline=440. + // y=1000: pixel = 440 - 1000*0.105 = 335 + // y=3000: pixel = 440 - 3000*0.105 = 125 + for + threshold <- Signal.initRef[Rev](Rev(1000.0)) + rows = Chunk(Sale("Jun", Rev(2000.0))) + spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + rule(y = (threshold: Signal[Rev])) + ).yScale(_.linear(0.0, 4000.0)) + root <- (spec).lower + html0 <- HtmlRenderer.render(root, Seq.empty) + // Update threshold and re-render. + _ <- threshold.set(Rev(3000.0)) + html1 <- HtmlRenderer.render(root, Seq.empty) + yield + // Both renders should contain SVG content for the reactive rule. + // The rule is wrapped in a Reactive[Svg.Line] inside a Svg.G. + // After threshold=1000, the rule y1/y2 is at pixel 335. + // After threshold=3000, the rule y1/y2 is at pixel 125. + // A vertical reactive rule is a single Svg.Line whose y1 and y2 BOTH equal the scaled + // pixel (125 after threshold=3000). Require BOTH endpoints with `&&`: an `||` check would + // green-light a rule with only one endpoint moved to the new pixel (a misplaced line). + assert( + html1.contains("y1=\"125") && html1.contains("y2=\"125"), + s"Reactive rule must place BOTH y1 and y2 at 125 (threshold=3000), got:\n${html1.take(2000)}" + ) + } + + // ---- Test 6: linked views: rule(x = hovered.map(...)) tracks published hover ---- + + "rule(x = hovered.map(...)) follows the hover signal from another chart" in { + // Layout: band x-scale over [Jan, Feb, Mar]. Chart B uses a rule tracking hovered month. + // Band scale: 3 categories in plotW=560. Each slot = 560/3 = 186.666... px. + // padding=0.1, bandW = 560*0.9/3 = 168.0 px + // Jan (i=0): left edge = plotX + (slot - bandW)/2 = 60 + 9.333... = 69.333... + // Jan center = left edge + bandW/2 = 69.333... + 84.0 = 153.33333333333331 + // The x-rule for a category is placed at the BAND CENTER (left edge + bandwidth/2) so the + // vertical guide line bisects the bar, not its left wall. + // Exact rendered string via NumberFormat.double: "153.33333333333331" + for + hovered <- Signal.initRef[Maybe[Sale]](Absent) + rowsA = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0)), Sale("Mar", Rev(3000.0))) + rowsB = Chunk(Sale("Jan", Rev(500.0)), Sale("Feb", Rev(1500.0)), Sale("Mar", Rev(2500.0))) + specA = Chart(rowsA)(bar(x = _.month, y = _.revenue)) + .onHover(hovered) + specB = Chart(rowsB)( + bar(x = _.month, y = _.revenue), + rule(x = hovered.map[Maybe[String]](_.map(_.month))) + ) + rootA <- (specA).lower + rootB <- (specB).lower + // Chart A: hover the "Jan" bar (index 0 in the chunk, first rect). + rectsA = rectsIn(rootA) + _ = assert(rectsA.size == 3, s"Expected 3 rects in chart A but got ${rectsA.size}") + janRect = rectsA(0) + _ = assert(janRect.attrs.onHover.isDefined, "Expected onHover on Jan rect in chart A") + // Simulate hover on Jan in chart A. + _ <- runAction(janRect.attrs.onHover.get) + // Render chart B; the reactive rule should reflect the hovered month "Jan". + htmlB <- HtmlRenderer.render(rootB, Seq.empty) + yield + // The rule is a vertical Svg.Line: x1 == x2 == band center for "Jan". + // Both endpoints must carry the exact center value, confirming this is the vertical rule + // and not a coincidental axis element. + val expectedX = "153.33333333333331" + assert( + htmlB.contains(s"""x1="$expectedX""""), + s"Expected rule x1=$expectedX in chart B HTML but got:\n${htmlB.take(2000)}" + ) + assert( + htmlB.contains(s"""x2="$expectedX""""), + s"Expected rule x2=$expectedX in chart B HTML but got:\n${htmlB.take(2000)}" + ) + } + + // ---- Test 7: static charts with no interaction stay clean ---- + + "static chart with no onHover/onSelect/tooltip has Absent handler attrs and no tooltip Reactive" in { + // A chart configured with only data and marks carries no interaction. The lowered shapes + // must not have onHover or onClick attached, and the root must contain no tooltip Reactive. + val rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + (spec).lower.map { root => + val rects = rectsIn(root) + assert(rects.size == 2, s"Expected 2 rects but got ${rects.size}") + // Each mark rect must have Absent onHover and Absent onClick: no spurious handler wiring. + val r0 = rects(0) + val r1 = rects(1) + assert(r0.attrs.onHover.isEmpty, s"rect[0]: Expected Absent onHover on static chart but got Present") + assert(r0.attrs.onClick.isEmpty, s"rect[0]: Expected Absent onClick on static chart but got Present") + assert(r1.attrs.onHover.isEmpty, s"rect[1]: Expected Absent onHover on static chart but got Present") + assert(r1.attrs.onClick.isEmpty, s"rect[1]: Expected Absent onClick on static chart but got Present") + // The root must contain no top-level Reactive children: a static chart has no tooltip overlay. + val topReactives = reactivesIn(root) + assert( + topReactives.isEmpty, + s"Expected no Reactive children in static chart root but got ${topReactives.size}" + ) + } + } + + /** Collect all Svg.Path children from a root, traversing one level of Svg.G. */ + private def pathsIn(root: Svg.Root): Chunk[Svg.Path] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case p: Svg.Path => Chunk(p) + case ig: Svg.G => ig.children.collect { case p: Svg.Path => p } + case _ => Chunk.empty + case _ => Chunk.empty + + // ---- line/area interaction ---- + + // line chart onSelect fires on click + "line chart with onSelect carries a Present onClick handler that fires" in { + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + spec = Chart(rows)(line(x = _.month, y = _.revenue)) + .onSelect(selectRef) + root <- (spec).lower + paths = pathsIn(root) + _ = assert(paths.nonEmpty, s"Expected at least one Svg.Path from line mark but got none") + // The line path should carry a click handler. + linePath = paths.toSeq.find(p => p.attrs.onClick.isDefined) + _ = assert(linePath.isDefined, "line path must carry Present onClick when onSelect is configured") + _ <- runAction(linePath.get.attrs.onClick.get) + after <- selectRef.get + yield assert( + after == Present(Sale("Jan", Rev(1000.0))), + s"After clicking the line path, selectRef must be Present(Sale(Jan, 1000)) but got $after" + ) + } + + // area chart onHover fires + "area chart with onHover carries a Present onHover handler that fires" in { + for + hoverRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + spec = Chart(rows)(area(x = _.month, y = _.revenue)) + .onHover(hoverRef) + root <- (spec).lower + paths = pathsIn(root) + _ = assert(paths.nonEmpty, s"Expected at least one Svg.Path from area mark but got none") + areaPath = paths.toSeq.find(p => p.attrs.onHover.isDefined) + _ = assert(areaPath.isDefined, "area path must carry Present onHover when onHover is configured") + _ <- runAction(areaPath.get.attrs.onHover.get) + after <- hoverRef.get + yield assert( + after == Present(Sale("Jan", Rev(1000.0))), + s"After hovering area path, hoverRef must be Present(Sale(Jan, 1000)) but got $after" + ) + } + + // stacked area attaches one handler per segment path + "stacked area with onSelect carries Present onClick on each segment path" in { + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk( + Sale("Jan", Rev(1000.0)), + Sale("Jan", Rev(500.0)), + Sale("Feb", Rev(2000.0)), + Sale("Feb", Rev(800.0)) + ) + // Use color encoding as the stack group so 2 groups are formed. + // area with stack: group 1 = rows 0,2; group 2 = rows 1,3. + spec = Chart(rows)(area( + x = _.month, + y = _.revenue, + color = _.revenue.toInt.toString, + stack = by(_.revenue.toInt.toString) + )) + .onSelect(selectRef) + root <- (spec).lower + paths = pathsIn(root) + interactivePaths = paths.toSeq.filter(p => p.attrs.onClick.isDefined) + // 4 distinct revenue values -> 4 stack groups -> one interactive path per group segment. + _ = assert( + interactivePaths.size == 4, + s"Stacked area with 4 stack groups must have exactly 4 paths with onClick, got ${interactivePaths.size} (${paths.size} total paths)" + ) + yield () + } + + // line single series has exactly one handler + "line without color split has exactly one interaction-bearing path" in { + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + spec = Chart(rows)(line(x = _.month, y = _.revenue)) + .onSelect(selectRef) + root <- (spec).lower + paths = pathsIn(root) + interactivePaths = paths.toSeq.filter(p => p.attrs.onClick.isDefined) + yield assert( + interactivePaths.size == 1, + s"Single-series line must have exactly 1 interaction-bearing path but got ${interactivePaths.size}" + ) + } + + // ---- highlight ---- + + /** Count Reactive nodes anywhere under the marks `` (the highlight region wraps the bars in a + * `Svg.g(Reactive[Svg.G])`). The lowering must create the highlight as a user-ref-driven Reactive and + * must NOT create an internal SignalRef of its own. + */ + private def markRegionReactives(root: Svg.Root): Chunk[Reactive[?]] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case r: Reactive[?] => Chunk(r) + case ig: Svg.G => ig.children.collect { case r: Reactive[?] => r } + case _ => Chunk.empty + case _ => Chunk.empty + + // highlightSelect drives the active bar's style from the select ref + "bar with highlightSelect: after the select ref is set, the active bar carries the select style" in { + // Default highlight (no custom style) is a dark 2px stroke outline on the active bar only. + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .onSelect(selectRef) + .interaction(_.highlightSelect) + root <- (spec).lower + // Before any selection: the ref is Absent, so no bar carries the highlight stroke. + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ = assert( + !htmlBefore.contains("stroke=\"#000000\""), + s"No bar may carry the select stroke before a selection, but got:\n${htmlBefore.take(2000)}" + ) + // Select the first row; the corresponding bar must now carry the select style. + _ <- selectRef.set(Present(Sale("Jan", Rev(1000.0)))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield + // The active bar carries the default select style: a dark 2px stroke. + assert( + htmlAfter.contains("stroke=\"#000000\"") && htmlAfter.contains("stroke-width=\"2px\""), + s"Selected bar must carry the select style (stroke=#000000, stroke-width=2px) but got:\n${htmlAfter.take(2000)}" + ) + // Exactly one bar is highlighted (the active row), not both. + val strokeOccurrences = "stroke=\"#000000\"".r.findAllMatchIn(htmlAfter).size + assert( + strokeOccurrences == 1, + s"Only the active bar may carry the select stroke, but found $strokeOccurrences occurrences" + ) + } + + // highlightHover with a custom hoverStyle applies that style value + "bar with a custom hoverStyle: the hovered bar carries the custom style value in the output" in { + // Custom hover style: a purple fill (Style.Color.purple == #a855f7), chosen distinct from the default + // palette fill (palette(0) == blue == #3b82f6). When the hover ref points at the row, the active bar's + // emitted fill must become the custom color. + for + hoverRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0))) + spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .onHover(hoverRef) + .interaction(_.hoverStyle(Style.bg(Style.Color.purple))) + root <- (spec).lower + // Before hover: the custom fill is absent (the bar uses the default palette blue). + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ = assert( + !htmlBefore.contains("fill=\"#a855f7\""), + s"Custom hover fill must not appear before hover, but got:\n${htmlBefore.take(2000)}" + ) + // Hover the row; the active bar must now carry the custom purple fill. + _ <- hoverRef.set(Present(Sale("Jan", Rev(1000.0)))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield assert( + htmlAfter.contains("fill=\"#a855f7\""), + s"Hovered bar must carry the custom hoverStyle fill (#a855f7) but got:\n${htmlAfter.take(2000)}" + ) + } + + // highlight with no ref configured is a no-op + "interaction(_.highlightSelect) with no onSelect configured is a no-op" in { + val rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .interaction(_.highlightSelect) // highlight configured, but no onSelect ref + (spec).lower.map { root => + val rects = rectsIn(root) + val withClick = rects.toSeq.filter(r => r.attrs.onClick.isDefined) + assert(rects.size == 2, s"Expected 2 rects but got ${rects.size}") + // No ref means no handlers and no highlight reactive region: bars are plain, no crash. + assert(withClick.isEmpty, "no onClick on rects when no onSelect ref is configured (no-op)") + assert(markRegionReactives(root).isEmpty, "no highlight reactive region without a ref (no-op)") + // No bar carries the default select stroke (highlight produced nothing). + for html <- HtmlRenderer.render(root, Seq.empty) + yield assert( + !html.contains("stroke=\"#000000\""), + s"No highlight style may appear without a ref, but got:\n${html.take(2000)}" + ) + } + } + + // highlight is a Reactive region driven by the user ref, with no internal cell + "highlight is a Reactive region driven by the user onSelect ref, with no internal SignalRef" in { + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0))) + spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .onSelect(selectRef) + .interaction(_.highlightSelect) + root <- (spec).lower + // The highlight is realized as a Reactive region inside the marks group (no internal cell: + // no tooltip is configured, so the ONLY reactive here is the highlight, and it is driven by + // the user's selectRef directly). + reactives = markRegionReactives(root) + _ = assert(reactives.size == 1, s"Expected exactly one highlight Reactive region but got ${reactives.size}") + // Driving the USER ref (not any internal ref) changes the rendered output: proof the region is + // bound to the user's selectRef, so there is no separate internal mutable interaction cell. + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ <- selectRef.set(Present(Sale("Jan", Rev(1000.0)))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield assert( + !htmlBefore.contains("stroke=\"#000000\"") && htmlAfter.contains("stroke=\"#000000\""), + s"Setting the user ref must drive the highlight region directly.\nbefore:\n${htmlBefore + .take(1500)}\nafter:\n${htmlAfter.take(1500)}" + ) + } + + // ---- interactive-legend tests ---- + + case class CatRow(x: String, y: Double, cat: String) derives CanEqual + + given CanEqual[Set[Int], Set[Int]] = CanEqual.derived + + /** Coord -> Double helper. */ + private def coordNum(c: Maybe[Svg.Coord]): Maybe[Double] = c match + case Present(Svg.Coord.Num(v)) => Present(v) + case _ => Absent + + /** Collect the legend swatch rects (the 12x12 frame-level rects, direct children of root). */ + private def legendSwatches(root: Svg.Root): Chunk[Svg.Rect] = + root.children.collect: + case r: Svg.Rect if coordNum(r.svgAttrs.width).contains(12.0) && coordNum(r.svgAttrs.height).contains(12.0) => r + + // ---- clicking a swatch toggles its index in the hiddenSeries ref ---- + + "clicking a legend swatch toggles its index in the user hiddenSeries ref" in { + val rows = Chunk(CatRow("p", 1.0, "catA"), CatRow("q", 2.0, "catB")) + for + hidden <- Signal.initRef(Set.empty[Int]) + spec = Chart(rows)(bar(x = _.x, y = _.y, color = _.cat)) + .legend(_.interactive(hidden)) + root <- (spec).lower + // The first swatch corresponds to catA (encounter order, index 0). Its onClick toggles index 0. + swatches = legendSwatches(root) + _ = assert(swatches.size == 2, s"Expected 2 legend swatches but got ${swatches.size}") + click = swatches(0).attrs.onClick + _ = assert(click.isDefined, "Expected Present onClick on the interactive swatch") + _ <- runAction(click.get) + after1 <- hidden.get + _ <- runAction(click.get) + after2 <- hidden.get + yield + assert(after1 == Set(0), s"First click should add index 0 (catA) but ref was $after1") + assert(after2 == Set.empty[Int], s"Second click should remove index 0 (catA) again but ref was $after2") + end for + } + + // ---- the hidden filter drops the specified series from the marks ---- + + "with hiddenSeries={0}, the catA bar (index 0) is dropped from the marks while catB remains" in { + // catA at x-band "p" (index 0), catB at x-band "q" (index 1). Hiding index 0 drops catA's bar. + val rowsFull = Chunk(CatRow("p", 1.0, "catA"), CatRow("q", 2.0, "catB")) + def markBars(root: Svg.Root): Chunk[Svg.Rect] = + rectsIn(root).filter(r => !(coordNum(r.svgAttrs.width).contains(12.0) && coordNum(r.svgAttrs.height).contains(12.0))) + for + none <- Signal.initRef(Set.empty[Int]) + hidden <- Signal.initRef(Set(0)) + specFull = Chart(rowsFull)(bar(x = _.x, y = _.y, color = _.cat)).legend(_.interactive(none)) + specHid = Chart(rowsFull)(bar(x = _.x, y = _.y, color = _.cat)).legend(_.interactive(hidden)) + rootFull <- (specFull).lower + rootHid <- (specHid).lower + html <- HtmlRenderer.render(rootHid, Seq.empty) + yield + val fullBars = markBars(rootFull) + val hidBars = markBars(rootHid) + assert(fullBars.size == 2, s"Expected 2 bars with nothing hidden but got ${fullBars.size}") + assert(hidBars.size == 1, s"Expected exactly 1 bar after hiding catA but got ${hidBars.size}") + // The surviving bar is catB's: its x-band differs from catA's band-"p" position. + val catAbandX = fullBars.map(b => coordNum(b.svgAttrs.x).getOrElse(-1.0)).min + val survivorX = coordNum(hidBars.head.svgAttrs.x).getOrElse(-1.0) + assert(survivorX != catAbandX, s"The surviving bar must be catB's (different band) but was at catA's band x=$catAbandX") + // The legend still shows BOTH category labels (so the user can toggle catA back on). + assert(html.contains(">catA<"), s"Legend must still show the hidden category label catA:\n$html") + assert(html.contains(">catB<"), s"Legend must show catB:\n$html") + end for + } + + // ---- the hidden filter applies before color-splitting ---- + + "with 3 series and catB hidden (index 1), mark colors index over the visible set {catA, catC} only" in { + val rows = Chunk( + CatRow("p", 1.0, "catA"), + CatRow("q", 2.0, "catB"), + CatRow("r", 3.0, "catC") + ) + for + hidden <- Signal.initRef(Set(1)) // catB is encounter index 1 + spec = Chart(rows)(bar(x = _.x, y = _.y, color = _.cat)) + .legend(_.interactive(hidden)) + root <- (spec).lower + yield + // Visible marks are catA and catC. With catB filtered BEFORE color-splitting, the visible set is + // {catA, catC}: catA -> palette(0)=blue, catC -> palette(1)=orange. (Not catC -> palette(2)=green, + // which is what would happen if the filter ran AFTER color-splitting over all three.) + val markFills = rectsIn(root).filter(r => + !(coordNum(r.svgAttrs.width).contains(12.0) && coordNum(r.svgAttrs.height).contains(12.0)) + ).map(r => r.svgAttrs.fill).toSeq + assert( + markFills.contains(Present(Svg.Paint.Color(Style.Color.blue))), + s"catA mark should be palette(0) blue but mark fills were: $markFills" + ) + assert( + markFills.contains(Present(Svg.Paint.Color(Style.Color.orange))), + s"catC mark should index to palette(1) orange (visible set), not palette(2); fills were: $markFills" + ) + assert( + !markFills.contains(Present(Svg.Paint.Color(Style.Color.green))), + s"No mark should use palette(2) green: the hidden filter ran before color-splitting; fills were: $markFills" + ) + end for + } + + // ---- hiding all series leaves the legend visible but the marks empty ---- + + "hiding all series leaves the legend swatches visible but the marks region empty" in { + val rows = Chunk(CatRow("p", 1.0, "catA"), CatRow("q", 2.0, "catB")) + for + hidden <- Signal.initRef(Set(0, 1)) // catA=index 0, catB=index 1 + spec = Chart(rows)(bar(x = _.x, y = _.y, color = _.cat)) + .legend(_.interactive(hidden)) + root <- (spec).lower + html <- HtmlRenderer.render(root, Seq.empty) + yield + // No mark rects (all series hidden): only the 12x12 legend swatches remain among rects. + val markFills = rectsIn(root).filter(r => + !(coordNum(r.svgAttrs.width).contains(12.0) && coordNum(r.svgAttrs.height).contains(12.0)) + ) + assert(markFills.isEmpty, s"All series hidden: expected no mark rects but found ${markFills.size}") + // The legend swatches and labels are still present (the user can toggle a series back on). + assert(legendSwatches(root).size == 2, s"Expected 2 legend swatches to remain visible but got ${legendSwatches(root).size}") + assert( + html.contains(">catA<") && html.contains(">catB<"), + s"Legend labels must remain visible when all series are hidden:\n$html" + ) + end for + } + + // ---- highlight coverage for line/area/text/errorBar ---- + + // Domain type for errorBar tests (needs low/high accessors). + case class EB(x: String, y: Double, lo: Double, hi: Double) derives CanEqual + + // line with highlightSelect: the active series path carries stroke="#000000" + "line with highlightSelect: after the select ref is set, the active series path carries the select style" in { + // 2-row chart: Jan and Feb. Select Jan; the single-series line path must carry the dark stroke. + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + spec = Chart(rows)(line(x = _.month, y = _.revenue)) + .onSelect(selectRef) + .interaction(_.highlightSelect) + root <- (spec).lower + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ = assert( + !htmlBefore.contains("stroke=\"#000000\""), + s"No line may carry the select stroke before selection, but got:\n${htmlBefore.take(2000)}" + ) + _ <- selectRef.set(Present(Sale("Jan", Rev(1000.0)))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield + assert( + htmlAfter.contains("stroke=\"#000000\"") && htmlAfter.contains("stroke-width=\"2px\""), + s"Selected line series must carry stroke=#000000 and stroke-width=2px but got:\n${htmlAfter.take(2000)}" + ) + // Single series: exactly 1 occurrence of the highlight stroke. + val strokeOccurrences = "stroke=\"#000000\"".r.findAllMatchIn(htmlAfter).size + assert( + strokeOccurrences == 1, + s"Only the active series path may carry the select stroke, but found $strokeOccurrences occurrences" + ) + } + + // area with highlightSelect: the active series path carries stroke="#000000" + "area with highlightSelect: after the select ref is set, the active series path carries the select style" in { + // 2-row chart: Jan and Feb. Select Jan; the single-series area path must carry the dark stroke. + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + spec = Chart(rows)(area(x = _.month, y = _.revenue)) + .yScale(_.linear(0, 2000)) + .onSelect(selectRef) + .interaction(_.highlightSelect) + root <- (spec).lower + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ = assert( + !htmlBefore.contains("stroke=\"#000000\""), + s"No area may carry the select stroke before selection, but got:\n${htmlBefore.take(2000)}" + ) + _ <- selectRef.set(Present(Sale("Jan", Rev(1000.0)))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield + assert( + htmlAfter.contains("stroke=\"#000000\"") && htmlAfter.contains("stroke-width=\"2px\""), + s"Selected area series must carry stroke=#000000 and stroke-width=2px but got:\n${htmlAfter.take(2000)}" + ) + // Single series: exactly 1 occurrence. + val strokeOccurrences = "stroke=\"#000000\"".r.findAllMatchIn(htmlAfter).size + assert( + strokeOccurrences == 1, + s"Only the active area series path may carry the select stroke, but found $strokeOccurrences occurrences" + ) + } + + // text with highlightSelect: the active glyph carries stroke="#000000" + "text with highlightSelect: after the select ref is set, the active glyph carries the select style" in { + // 2-row chart: Jan and Feb. Select Jan; only the Jan glyph must carry the dark stroke. + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + spec = Chart(rows)(text(x = _.month, y = _.revenue, label = _.month)) + .yScale(_.linear(0, 2000)) + .onSelect(selectRef) + .interaction(_.highlightSelect) + root <- (spec).lower + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ = assert( + !htmlBefore.contains("stroke=\"#000000\""), + s"No text glyph may carry the select stroke before selection, but got:\n${htmlBefore.take(2000)}" + ) + _ <- selectRef.set(Present(Sale("Jan", Rev(1000.0)))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield + assert( + htmlAfter.contains("stroke=\"#000000\"") && htmlAfter.contains("stroke-width=\"2px\""), + s"Selected text glyph must carry stroke=#000000 and stroke-width=2px but got:\n${htmlAfter.take(2000)}" + ) + // 2 rows, 1 active: exactly 1 occurrence. + val strokeOccurrences = "stroke=\"#000000\"".r.findAllMatchIn(htmlAfter).size + assert( + strokeOccurrences == 1, + s"Only the active text glyph may carry the select stroke, but found $strokeOccurrences occurrences" + ) + } + + // errorBar with highlightSelect: the active row GROUP carries stroke="#000000" once + "errorBar with highlightSelect: after the select ref is set, the active row group carries the select style once" in { + // 2-row chart: Jan and Feb. Select Jan; the Jan error-bar GROUP must carry the dark stroke exactly once. + // The group wraps the 4 sub-shapes (vLine, capLow, capHigh, marker) so highlight fires once, not 4 times. + for + selectRef <- Signal.initRef[Maybe[EB]](Absent) + rows = Chunk(EB("Jan", 1000.0, 800.0, 1200.0), EB("Feb", 2000.0, 1700.0, 2300.0)) + spec = Chart(rows)( + errorBar(x = _.x, y = _.y, low = _.lo, high = _.hi) + ) + .yScale(_.linear(0, 3000)) + .onSelect(selectRef) + .interaction(_.highlightSelect) + root <- (spec).lower + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ = assert( + !htmlBefore.contains("stroke=\"#000000\""), + s"No errorBar element may carry the select stroke before selection, but got:\n${htmlBefore.take(2000)}" + ) + _ <- selectRef.set(Present(EB("Jan", 1000.0, 800.0, 1200.0))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield + assert( + htmlAfter.contains("stroke=\"#000000\"") && htmlAfter.contains("stroke-width=\"2px\""), + s"Selected errorBar group must carry stroke=#000000 and stroke-width=2px but got:\n${htmlAfter.take(2000)}" + ) + // The group's stroke="#000000" must appear exactly once (on the group, not on the 4 sub-shapes + // individually). This validates the Svg.g grouping approach. + val strokeOccurrences = "stroke=\"#000000\"".r.findAllMatchIn(htmlAfter).size + assert( + strokeOccurrences == 1, + s"The highlight stroke must appear exactly once (on the group element), but found $strokeOccurrences occurrences" + ) + } + + // ---- Live-path tests: interaction on animated bar/line/area ---- + // These tests verify that the animated (live-chart) arms of marksRegionWithTransitions + // correctly attach onClick/onHover handlers and withHighlight, + // so a Chart(signal) with onSelect/onHover/highlightSelect propagates interaction configuration. + + // Test 20: live bar carries onClick handler from onSelect + "LIVE bar with onSelect: rendered rect carries data-kyo-ev=click" in { + // AnimateConfig.default.enabled=true, so Chart(signal)(...) routes through + // marksRegionWithTransitions -> lowerBarSimpleWithTransitions. That arm must call + // buildInteractionAttrs so the data-kyo-ev attribute is emitted. + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + signal = Signal.initConst[Seq[Sale]](rows) + spec = Chart(signal: Signal[Seq[Sale]])(bar(x = _.month, y = _.revenue)) + .onSelect(selectRef) + root <- (spec).lower + html <- HtmlRenderer.render(root, Seq.empty) + yield assert( + html.contains("data-kyo-ev") && html.contains("click"), + s"LIVE bar with onSelect must carry data-kyo-ev=click on rendered rects, but got:\n${html.take(3000)}" + ) + } + + // Test 21: live bar with highlightSelect: after setting selectRef the active bar carries the select stroke + "LIVE bar with highlightSelect: after selectRef is set, the active bar carries stroke=#000000" in { + // withHighlight must be called in lowerBarSimpleWithTransitions; + // a Reactive highlight region must be created so the select stroke appears. + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + signal = Signal.initConst[Seq[Sale]](rows) + spec = Chart(signal: Signal[Seq[Sale]])(bar(x = _.month, y = _.revenue)) + .onSelect(selectRef) + .interaction(_.highlightSelect) + root <- (spec).lower + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ = assert( + !htmlBefore.contains("stroke=\"#000000\""), + s"No bar may carry the select stroke before selection on a live chart, but got:\n${htmlBefore.take(2000)}" + ) + _ <- selectRef.set(Present(Sale("Jan", Rev(1000.0)))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield assert( + htmlAfter.contains("stroke=\"#000000\"") && htmlAfter.contains("stroke-width=\"2px\""), + s"LIVE bar with highlightSelect must carry stroke=#000000 after selection, but got:\n${htmlAfter.take(2000)}" + ) + } + + // Test 22: live line carries onClick handler from onSelect + "LIVE line with onSelect: rendered path carries data-kyo-ev=click" in { + // lowerLineWithTransitions must call lowerLineSeries with spec/internalHoverRef, + // so interaction attrs are attached to the line path. + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + signal = Signal.initConst[Seq[Sale]](rows) + spec = Chart(signal: Signal[Seq[Sale]])(line(x = _.month, y = _.revenue)) + .onSelect(selectRef) + root <- (spec).lower + html <- HtmlRenderer.render(root, Seq.empty) + yield assert( + html.contains("data-kyo-ev") && html.contains("click"), + s"LIVE line with onSelect must carry data-kyo-ev=click on rendered path, but got:\n${html.take(3000)}" + ) + } + + // Test 23: live line with highlightSelect fires after selection + "LIVE line with highlightSelect: after selectRef is set, the active series path carries stroke=#000000" in { + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + signal = Signal.initConst[Seq[Sale]](rows) + spec = Chart(signal: Signal[Seq[Sale]])(line(x = _.month, y = _.revenue)) + .onSelect(selectRef) + .interaction(_.highlightSelect) + root <- (spec).lower + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ = assert( + !htmlBefore.contains("stroke=\"#000000\""), + s"No live line may carry the select stroke before selection, but got:\n${htmlBefore.take(2000)}" + ) + _ <- selectRef.set(Present(Sale("Jan", Rev(1000.0)))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield assert( + htmlAfter.contains("stroke=\"#000000\"") && htmlAfter.contains("stroke-width=\"2px\""), + s"LIVE line with highlightSelect must carry stroke=#000000 after selection, but got:\n${htmlAfter.take(2000)}" + ) + } + + // Test 24: live area carries onClick handler from onSelect + "LIVE area with onSelect: rendered path carries data-kyo-ev=click" in { + // lowerAreaWithTransitions must call lowerArea with internalHoverRef for the + // non-stacked path, so interaction attrs are on the area path element. + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + signal = Signal.initConst[Seq[Sale]](rows) + spec = Chart(signal: Signal[Seq[Sale]])(area(x = _.month, y = _.revenue)) + .onSelect(selectRef) + root <- (spec).lower + html <- HtmlRenderer.render(root, Seq.empty) + yield assert( + html.contains("data-kyo-ev") && html.contains("click"), + s"LIVE area with onSelect must carry data-kyo-ev=click on rendered path, but got:\n${html.take(3000)}" + ) + } + + // Test 25: live area with highlightSelect fires after selection + "LIVE area with highlightSelect: after selectRef is set, the active series path carries stroke=#000000" in { + for + selectRef <- Signal.initRef[Maybe[Sale]](Absent) + rows = Chunk(Sale("Jan", Rev(1000.0)), Sale("Feb", Rev(2000.0))) + signal = Signal.initConst[Seq[Sale]](rows) + spec = Chart(signal: Signal[Seq[Sale]])(area(x = _.month, y = _.revenue)) + .yScale(_.linear(0, 2000)) + .onSelect(selectRef) + .interaction(_.highlightSelect) + root <- (spec).lower + htmlBefore <- HtmlRenderer.render(root, Seq.empty) + _ = assert( + !htmlBefore.contains("stroke=\"#000000\""), + s"No live area may carry the select stroke before selection, but got:\n${htmlBefore.take(2000)}" + ) + _ <- selectRef.set(Present(Sale("Jan", Rev(1000.0)))) + htmlAfter <- HtmlRenderer.render(root, Seq.empty) + yield assert( + htmlAfter.contains("stroke=\"#000000\"") && htmlAfter.contains("stroke-width=\"2px\""), + s"LIVE area with highlightSelect must carry stroke=#000000 after selection, but got:\n${htmlAfter.take(2000)}" + ) + } + + // ---- interactive legend hide-set keyed by series index ---- + + "colliding-toString categories toggle independently via index-based hiddenSeries Set[Int]" in { + // Two color categories whose toString both return "x". A Set[String] hide-set would map both + // categories to the key "x", so toggling one would hide BOTH. With index-based keying, + // index 0 toggles only category A and index 1 toggles only category B. + enum Col derives CanEqual, Plottable: + case A, B + override def toString: String = "x" + + case class ColRow(pos: String, v: Double, col: Col) derives CanEqual + + val rows = Chunk( + ColRow("p", 1.0, Col.A), + ColRow("q", 2.0, Col.B) + ) + + for + hidden <- Signal.initRef(Set.empty[Int]) + spec = Chart(rows)(bar(x = _.pos, y = _.v, color = _.col)) + .legend(_.interactive(hidden)) + root <- (spec).lower + // The legend should have 2 swatches, one per color category. + swatches = legendSwatches(root) + _ = assert(swatches.size == 2, s"Expected 2 legend swatches but got ${swatches.size}") + // Click the first swatch: should toggle index 0 (Col.A only). + click0 = swatches(0).attrs.onClick + _ = assert(click0.isDefined, "First swatch must have an onClick") + _ <- runAction(click0.get) + after0 <- hidden.get + _ = assert(after0 == Set(0), s"After clicking swatch 0, hiddenSeries must be Set(0) but was $after0") + // Render the chart: only Col.A rows should be filtered; Col.B must still produce a bar. + rootHidden <- (spec).lower + yield + // Build a fresh root from the same spec to reflect the current hidden state. + // After hiding index 0 (Col.A), Col.B's row must still be rendered. + // We compute it by lowering again (hiddenSeries ref is read synchronously at lower time). + val markBars = rectsIn(rootHidden).filter(r => + !(coordNum(r.svgAttrs.width).contains(12.0) && coordNum(r.svgAttrs.height).contains(12.0)) + ) + // Col.A is hidden, Col.B is visible: exactly 1 bar remains. + assert( + markBars.size == 1, + s"Hiding index 0 (Col.A) must leave exactly 1 bar (Col.B), but found ${markBars.size}" + ) + end for + } + +end ChartInteractionTest diff --git a/kyo-ui/shared/src/test/scala/kyo/ChartInvariantsTest.scala b/kyo-ui/shared/src/test/scala/kyo/ChartInvariantsTest.scala new file mode 100644 index 0000000000..c98387c9e7 --- /dev/null +++ b/kyo-ui/shared/src/test/scala/kyo/ChartInvariantsTest.scala @@ -0,0 +1,269 @@ +package kyo + +import kyo.Chart.* +import kyo.UI.* +import kyo.UI.Ast.* +import kyo.internal.ChartFoundations +import kyo.internal.Extent +import kyo.internal.HtmlRenderer +import kyo.internal.Scale + +/** Smoke tests that pin the core foundation invariants. + * + * Each test is a focused "crash-if-violated" assertion rather than a full geometry + * regression. Heavy behavioral coverage is in the axis, lower, and morph test suites. + */ +class ChartInvariantsTest extends kyo.test.Test[Any]: + + // ---- NaN y does not poison ticks or coordinates ---- + + "NaN y value does not appear in lowered SVG HTML output" in { + case class Row(x: Int, y: Double) + val rows = Chunk(Row(0, 1.0), Row(1, Double.NaN), Row(2, 3.0)) + val spec = Chart(rows)(bar(x = _.x, y = _.y)) + for + root <- spec.lower + html <- HtmlRenderer.render(root, Seq.empty) + yield + assert(!html.contains("NaN"), s"SVG output must not contain 'NaN' but got: ${html.take(200)}") + assert(!html.contains("Infinity"), s"SVG output must not contain 'Infinity'") + end for + } + + // NaN/Infinity must not appear in point/line chart SVG output (exercises Scale.apply directly) + + "NaN y value does not appear in POINT chart SVG output" in { + case class Row(x: Int, y: Double) + val rows = Chunk(Row(0, 1.0), Row(1, Double.NaN), Row(2, Double.PositiveInfinity), Row(3, 3.0)) + val spec = Chart(rows)(point(x = _.x, y = _.y)) + for + root <- spec.lower + html <- HtmlRenderer.render(root, Seq.empty) + yield + assert(!html.contains("NaN"), s"Point chart SVG must not contain 'NaN' but got: ${html.take(200)}") + assert(!html.contains("Infinity"), s"Point chart SVG must not contain 'Infinity'") + end for + } + + "NaN y value does not appear in LINE chart SVG output" in { + case class Row(x: Int, y: Double) + val rows = Chunk(Row(0, 1.0), Row(1, Double.NaN), Row(2, Double.PositiveInfinity), Row(3, 3.0)) + val spec = Chart(rows)(line(x = _.x, y = _.y)) + for + root <- spec.lower + html <- HtmlRenderer.render(root, Seq.empty) + yield + assert(!html.contains("NaN"), s"Line chart SVG must not contain 'NaN' but got: ${html.take(200)}") + assert(!html.contains("Infinity"), s"Line chart SVG must not contain 'Infinity'") + end for + } + + // ---- single-pass resolveAllScales is byte-identical to the baseline ---- + + "single-pass scale resolution produces a non-empty SVG matching the baseline" in { + // A 3-mark chart (bar + line + point) with a right axis exercises all scale-resolution paths. + case class Row(x: String, yL: Double, yR: Double) + val rows = Chunk( + Row("Jan", 10.0, 100.0), + Row("Feb", 20.0, 200.0), + Row("Mar", 15.0, 150.0) + ) + val spec = Chart(rows)( + bar(x = _.x, y = _.yL), + line(x = _.x, y = _.yL), + point(x = _.x, y = _.yR, axis = Axis.Right) + ).yAxisRight(identity) + + for + root1 <- spec.lower + root2 <- spec.lower + html1 <- HtmlRenderer.render(root1, Seq.empty) + html2 <- HtmlRenderer.render(root2, Seq.empty) + yield + assert(html1.nonEmpty, "SVG output must be non-empty") + assert(html1 == html2, "Two lowerings of the same spec must be byte-identical") + end for + } + + // ---- golden full-SVG string pins the fused single-pass scale resolution ---- + + "golden SVG pins the fused single-pass scale resolution" in { + // Same no-gradient 3-mark (bar + line + point) right-axis chart as the determinism test above. + // It emits NO (no sequential colorScale), so the AtomicInt gradient-id prefix + // never appears and the rendered HTML is fully deterministic. A future refactor that perturbs the + // fused extent/scale walk fails this exact-string golden loudly instead of silently. + case class Row(x: String, yL: Double, yR: Double) + val rows = Chunk( + Row("Jan", 10.0, 100.0), + Row("Feb", 20.0, 200.0), + Row("Mar", 15.0, 150.0) + ) + val spec = Chart(rows)( + bar(x = _.x, y = _.yL), + line(x = _.x, y = _.yL), + point(x = _.x, y = _.yR, axis = Axis.Right) + ).yAxisRight(identity) + for + root <- spec.lower + html <- HtmlRenderer.render(root, Seq.empty) + yield + assert(!html.contains("linearGradient"), "golden chart must emit no gradient (determinism guard)") + assert(!html.contains("kyo-chart-"), "golden chart must carry no non-deterministic chart-id prefix") + assert(html == ChartInvariantsTest.expectedGolden, s"golden SVG drift:\n$html") + end for + } + + // ---- ScaleOverride.pad wins over AxisConfig.pad ---- + + "ScaleOverride.withPad(0.2) wins over AxisConfig.pad(0.05) for extent widening" in { + // The chart uses a linear x scale with known domain [0,10]. + // ScaleOverride.withPad(0.2) should widen by 20%: delta = 0.2*(10-0) = 2; domain -> [-2,12]. + // AxisConfig.pad(0.05) would widen by only 5%: delta = 0.5; domain -> [-0.5,10.5]. + // We verify by rendering with both and checking the resolved scale: the fitted linear scale + // domain min should be around -2 (not -0.5) confirming ScaleOverride wins. + // noNice is required here so the pad difference is observable: nice now snaps to step-aligned + // bounds (floor lo / ceil hi to the nice step), and both [-2,12] and [-0.5,10.5] would snap to + // the same [-5,15], hiding which pad won. With noNice the resolved domain is the padded extent + // verbatim, so [-2,12] (override) is distinguishable from [-0.5,10.5] (AxisConfig). + case class Row(x: Double, y: Double) + val rows = Chunk(Row(0.0, 1.0), Row(5.0, 2.0), Row(10.0, 3.0)) + val spec = Chart(rows)(bar(x = _.x, y = _.y)) + .xScale(_.linear(0.0, 10.0).withPad(0.2).noNice) + .xAxis(_.pad(0.05)) + // Read the resolved x-scale back: ScaleOverride.withPad(0.2) widens [0,10] by 0.2*(10-0)=2 each + // side -> [-2,12]; AxisConfig.pad(0.05) would give [-0.5,10.5]. The OVERRIDE must win. + spec.lowerWithScales.map { (_, sc) => + sc.x.kind match + case ScaleKind.Linear(lo, hi) => + assert( + math.abs(lo - -2.0) < 1e-9, + s"ScaleOverride.withPad(0.2) must widen domain min to -2.0 (not AxisConfig.pad's -0.5), got $lo" + ) + assert( + math.abs(hi - 12.0) < 1e-9, + s"ScaleOverride.withPad(0.2) must widen domain max to 12.0, got $hi" + ) + case other => fail(s"Expected ScaleKind.Linear but got $other") + end match + } + } + + // reversed=true via AxisConfig places first datum at the far range end. + "AxisConfig.reverse flips pixel orientation (first datum at far range end)" in { + case class Row(x: String, y: Double) + val rows = Chunk(Row("a", 1.0), Row("b", 2.0), Row("c", 3.0)) + // Forward control (no reverse): the first category 'a' sits left of the last 'c'. + val fwdSpec = Chart(rows)(bar(x = _.x, y = _.y)) + // Reversed: the first category 'a' must project to the FAR (right) end, the last 'c' to the near end. + val revSpec = Chart(rows)(bar(x = _.x, y = _.y)).xAxis(_.reverse) + for + (_, fwdSc) <- fwdSpec.lowerWithScales + fwdA = fwdSc.x.toPixelCategory("a").getOrElse(fail("forward: band key 'a' must project")) + fwdC = fwdSc.x.toPixelCategory("c").getOrElse(fail("forward: band key 'c' must project")) + _ = assert(fwdA < fwdC, s"forward (no reverse): first band 'a' must be left of 'c', px(a)=$fwdA, px(c)=$fwdC") + (_, sc) <- revSpec.lowerWithScales + pa = sc.x.toPixelCategory("a").getOrElse(fail("reverse: band key 'a' must project")) + pc = sc.x.toPixelCategory("c").getOrElse(fail("reverse: band key 'c' must project")) + yield + assert(pa > pc, s"reverse must place first datum 'a' at the far (right) end: px(a)=$pa must exceed px(c)=$pc") + // 'a' must sit in the right half of the plot under reverse. + assert( + pa > sc.plot.x + sc.plot.width / 2.0, + s"reversed first band must be in the right half, px(a)=$pa, plot=[${sc.plot.x}, ${sc.plot.x + sc.plot.width}]" + ) + end for + } + + // ---- interaction smoke tests ---- + + // rule defaults to RuleValue.Unset; a both-Unset rule is skipped at lowering (empty Chunk) + // so it emits NO Svg.Line, while a sibling bar still renders a rect. + "rule() with both-Unset positions emits no rule line while the sibling bar renders" in { + case class Row(x: String, y: Double) + val rows = Chunk(Row("a", 1.0)) + val spec = Chart(rows)(bar(x = _.x, y = _.y), rule[Row, Double]()) + spec.lower.map { root => + // In the static lowering the marks live in a single Svg.G; chrome (axis) lines are direct root + // children (not inside a G). Scope the line check to the marks group so axis lines do not leak in. + val marksGroups = root.children.collect { case g: Svg.G => g }.filter(g => + g.children.exists { case _: Svg.Rect => true; case _ => false } + ) + assert(marksGroups.nonEmpty, "the sibling bar must still render inside a marks Svg.G") + val marksRects = marksGroups.flatMap(_.children.collect { case r: Svg.Rect => r }) + val marksLines = marksGroups.flatMap(_.children.collect { case l: Svg.Line => l }) + assert(marksRects.nonEmpty, "the sibling bar must still render a rect") + assert(marksLines.isEmpty, "a rule() with both positions Unset must emit NO Svg.Line (skipped at lowering)") + } + } + + // text mark lowers to Svg.Text elements and contributes to extent. + "text mark lowers to at least one Svg.Text element (crash-if-violated)" in { + case class Row(x: String, y: Double) + val rows = Chunk(Row("a", 5.0), Row("b", 3.0)) + val spec = Chart(rows)(text(x = _.x, y = _.y, label = _.x)) + for + root <- spec.lower + html <- HtmlRenderer.render(root, Seq.empty) + yield assert(html.nonEmpty, "text mark must produce non-empty SVG") + end for + } + + // errorBar lowers to plain SVG lines/circles with no url(#id). + "errorBar lowers without url(# references (crash-if-violated)" in { + case class Row(x: String, mean: Double, lo: Double, hi: Double) + val rows = Chunk(Row("a", 6.0, 4.0, 8.0)) + val spec = Chart(rows)(errorBar(x = _.x, y = _.mean, low = _.lo, high = _.hi)) + for + root <- spec.lower + html <- HtmlRenderer.render(root, Seq.empty) + yield + assert(!html.contains("url(#"), "errorBar must not emit url(#...) references") + assert(html.nonEmpty, "errorBar must produce non-empty SVG") + end for + } + + // line mark with onSelect carries a click handler. + "line mark with onSelect carries a Present onClick on the rendered path" in { + case class Pt(x: String, y: Double) + given CanEqual[Pt, Pt] = CanEqual.derived + for + selectRef <- Signal.initRef[Maybe[Pt]](Absent) + rows = Chunk(Pt("a", 1.0), Pt("b", 2.0)) + spec = Chart(rows)(line(x = _.x, y = _.y)) + .onSelect(selectRef) + root <- spec.lower + paths = root.children.flatMap: + case g: Svg.G => g.children.collect { case p: Svg.Path => p } + case _ => Chunk.empty + interactivePaths = paths.toSeq.filter(p => p.attrs.onClick.isDefined) + yield assert(interactivePaths.nonEmpty, "line mark with onSelect must carry onClick on path") + end for + } + + // interaction(_.highlightSelect) with no onSelect is a no-op: the rendered HTML must be + // identical to the same spec without the interaction config. + "interaction(_.highlightSelect) with no onSelect ref is a no-op: renders identically to the plain spec" in { + case class Row(x: String, y: Double) + val rows = Chunk(Row("a", 1.0)) + val plain = Chart(rows)(bar(x = _.x, y = _.y)) + val withInt = Chart(rows)(bar(x = _.x, y = _.y)).interaction(_.highlightSelect) + for + rootPlain <- plain.lower + rootWithInt <- withInt.lower + htmlPlain <- HtmlRenderer.render(rootPlain, Seq.empty) + htmlWithInt <- HtmlRenderer.render(rootWithInt, Seq.empty) + yield assert( + htmlPlain == htmlWithInt, + "interaction(_.highlightSelect) with no onSelect ref must be a no-op: rendered HTML must equal the plain spec" + ) + end for + } + +end ChartInvariantsTest + +object ChartInvariantsTest: + // Captured verbatim from a green run of the no-gradient 3-mark right-axis chart. Regenerate (never + // hand-edit) from a green run if the rendering legitimately changes; the chart must stay no-gradient. + val expectedGolden: String = + """05101520100150200JanFebMar""" +end ChartInvariantsTest diff --git a/kyo-ui/shared/src/test/scala/kyo/ChartLowerTest.scala b/kyo-ui/shared/src/test/scala/kyo/ChartLowerTest.scala new file mode 100644 index 0000000000..46b960a317 --- /dev/null +++ b/kyo-ui/shared/src/test/scala/kyo/ChartLowerTest.scala @@ -0,0 +1,3426 @@ +package kyo + +import kyo.Chart.* +import kyo.Chart.Encoding +import kyo.Svg.Coord +import kyo.Svg.PathCommand +import kyo.Svg.PathData +import kyo.UI.* +import kyo.UI.Ast.* +import kyo.internal.ChartLower +import kyo.internal.HtmlRenderer +import kyo.internal.NumberFormat +import kyo.internal.Scale +import scala.language.implicitConversions + +class ChartLowerTest extends kyo.test.Test[Any]: + + // ---- shared domain types ---- + + enum Region derives CanEqual, Plottable: + case NA, EU, APAC + + opaque type Usd <: Double = Double + object Usd: + def apply(d: Double): Usd = d + given Plottable[Usd] = Plottable.numeric + given CanEqual[Usd, Usd] = CanEqual.derived + implicit def doubleToUsd(d: Double): Usd = d + end Usd + + case class Sale(month: String, revenue: Usd, region: Region = Region.NA) + given CanEqual[Sale, Sale] = CanEqual.derived + + // Helper: collect all Svg.Rect children from a Root's marks g + private def rectsIn(root: Svg.Root): Chunk[Svg.Rect] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case r: Svg.Rect => Chunk(r) + case _ => Chunk.empty + case _ => Chunk.empty + + private def pathsIn(root: Svg.Root): Chunk[Svg.Path] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case p: Svg.Path => Chunk(p) + case _ => Chunk.empty + case _ => Chunk.empty + + private def circlesIn(root: Svg.Root): Chunk[Svg.Circle] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case c: Svg.Circle => Chunk(c) + case _ => Chunk.empty + case _ => Chunk.empty + + private def linesIn(root: Svg.Root): Chunk[Svg.Line] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case l: Svg.Line => Chunk(l) + case _ => Chunk.empty + case _ => Chunk.empty + + // The marks region is the last top-level Svg.G child appended by lowerStatic (chrome groups precede it). + private def marksGroup(root: Svg.Root)(using Frame, kyo.test.AssertScope): Svg.G = + root.children.collect { case g: Svg.G => g }.lastOption.getOrElse(fail("no marks Svg.G found")) + + // Layout constants (must match ChartLower defaults). + private val PlotX = 60.0 + private val PlotY = 20.0 + private val PlotW = 560.0 + private val PlotH = 420.0 + private val Baseline = PlotY + PlotH // 440.0 + + private val Tol = 1e-6 + + private def assertClose(actual: Double, expected: Double, msg: String)(using Frame, kyo.test.AssertScope): Unit = + assert(math.abs(actual - expected) < Tol, s"$msg: expected $expected but got $actual") + + private def numOf(c: Maybe[Coord])(using Frame, kyo.test.AssertScope): Double = c match + case Present(Coord.Num(v)) => v + case other => fail(s"Expected Coord.Num but got $other") + + // ---- Test 1: single bar ---- + + "single bar lowers to one Svg.Rect with exact scaled x/y/width/height" in { + // Data: one row, one category "Jan" on x, revenue=1000 on y. + // x Band: n=1, slot=560, bandW=560*0.9=504 + // barX = PlotX + (slot - bandW)/2 = 60 + 28 = 88 + // y Linear: niceTicks(0,1000,5) => step=500 => nLo=0, nHi=1000 + // Scale.Linear(0, 1000, 440, 20) => apply(1000) = 20 + // barY=20, barH=420 + val rows = Chunk(Sale("Jan", Usd(1000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + (spec).lower.map { root => + val rects = rectsIn(root) + assert(rects.size == 1, s"Expected 1 rect but got ${rects.size}") + val r = rects(0) + val expectedBandW = 504.0 + val expectedBarX = PlotX + (560.0 - expectedBandW) / 2.0 // 88.0 + assertClose(numOf(r.svgAttrs.x), expectedBarX, "barX") + assertClose(numOf(r.svgAttrs.width), expectedBandW, "barW") + assertClose(numOf(r.svgAttrs.y), 20.0, "barY") + assertClose(numOf(r.svgAttrs.height), 420.0, "barH") + } + } + + // ---- Test 2: two-point line ---- + + "two-point line lowers to one Svg.Path with exact PathData (MoveTo then LineTo)" in { + // Rows: ("a", 100), ("b", 200), x:String => Band, y:Int => Linear + // x Band: n=2, slot=280, bandW=252 + // "a": px = 60 + 0*280 + (280-252)/2 = 60+14 = 74 + // "b": px = 60 + 1*280 + 14 = 354 + // y Linear: niceTicks(100,200,5) => step=50, nLo=100, nHi=200 + // Scale.Linear(100,200,440,20) + // apply(100) = 440, apply(200) = 20 + case class Row(x: String, y: Int) + val rows = Chunk(Row("a", 100), Row("b", 200)) + val spec = Chart(rows)(line(x = _.x, y = _.y)) + (spec).lower.map { root => + val paths = pathsIn(root) + assert(paths.size == 1, s"Expected 1 path but got ${paths.size}") + val cmds = paths(0).svgAttrs.d match + case Present(pd) => PathData.commands(pd) + case Absent => fail("Expected path to have d attribute") + // Band: n=2, slot=280, bandW=252, padding=(280-252)/2=14. + // Line vertices sit at the band CENTRE (left edge + bandW/2), aligning with the centred x-tick labels. + // (74/354 would be the band LEFT edge, half a band off from the tick.) + val slot = 280.0 + val bandW = 252.0 + val pad = (slot - bandW) / 2.0 // 14.0 + val px_a = PlotX + 0 * slot + pad + bandW / 2 // 200.0 (band centre) + val px_b = PlotX + 1 * slot + pad + bandW / 2 // 480.0 (band centre) + assert(cmds.size == 2, s"Expected 2 path commands but got ${cmds.size}") + cmds(0) match + case PathCommand.MoveTo(x, y) => + assertClose(x, px_a, "MoveTo x") + assertClose(y, 440.0, "MoveTo y") + case other => fail(s"Expected MoveTo but got $other") + end match + cmds(1) match + case PathCommand.LineTo(x, y) => + assertClose(x, px_b, "LineTo x") + assertClose(y, 20.0, "LineTo y") + case other => fail(s"Expected LineTo but got $other") + end match + } + } + + // ---- Test 3: area lowers to a closed path ---- + + "area lowers to a closed Svg.Path (top edge forward, baseline back, Close)" in { + // Same data as line test: ("a", 100), ("b", 200) + // area y: extent with ensureZero => Continuous(0, 200) + // niceTicks(0,200,5) => step=50, nLo=0, nHi=200 + // Scale.Linear(0,200,440,20): + // apply(100)=230, apply(200)=20 + // x Band: n=2 ["a","b"], slot=280, bandW=252, pad=14 + // px_a=74, px_b=354 + // Path: from(74,230).lineTo(354,20).lineTo(354,440).lineTo(74,440).close + case class Row(x: String, y: Int) + val rows = Chunk(Row("a", 100), Row("b", 200)) + val spec = Chart(rows)(area(x = _.x, y = _.y)) + (spec).lower.map { root => + val paths = pathsIn(root) + assert(paths.size == 1, s"Expected 1 path but got ${paths.size}") + val cmds = paths(0).svgAttrs.d match + case Present(pd) => PathData.commands(pd) + case Absent => fail("Expected path to have d attribute") + // Area vertices sit at the band CENTRE (left edge + bandW/2), aligning with the centred x-tick labels. + // (74/354 would be the band LEFT edge, half a band off from the tick.) + val slot = 280.0 + val bandW = 252.0 + val pad = (slot - bandW) / 2.0 + val px_a = PlotX + pad + bandW / 2 // 200.0 (band centre) + val px_b = PlotX + slot + pad + bandW / 2 // 480.0 (band centre) + // Commands: MoveTo(px_a,230), LineTo(px_b,20), LineTo(px_b,440), LineTo(px_a,440), Close + assert(cmds.size == 5, s"Expected 5 path commands but got ${cmds.size}") + cmds(0) match + case PathCommand.MoveTo(x, y) => + assertClose(x, px_a, "area MoveTo x") + assertClose(y, 230.0, "area MoveTo y") + case other => fail(s"Expected MoveTo but got $other") + end match + cmds(1) match + case PathCommand.LineTo(x, y) => + assertClose(x, px_b, "area LineTo(top) x") + assertClose(y, 20.0, "area LineTo(top) y") + case other => fail(s"Expected LineTo but got $other") + end match + cmds(2) match + case PathCommand.LineTo(x, y) => + assertClose(x, px_b, "area LineTo(baseline-last) x") + assertClose(y, Baseline, "area LineTo(baseline-last) y") + case other => fail(s"Expected LineTo(baseline-last) but got $other") + end match + cmds(3) match + case PathCommand.LineTo(x, y) => + assertClose(x, px_a, "area LineTo(baseline-first) x") + assertClose(y, Baseline, "area LineTo(baseline-first) y") + case other => fail(s"Expected LineTo(baseline-first) but got $other") + end match + assert(cmds(4) == PathCommand.Close, s"Expected Close but got ${cmds(4)}") + } + } + + // ---- Test 4: point lowers to Svg.Circle ---- + + "point lowers to an Svg.Circle with scaled cx/cy" in { + // Rows: ("a", 0), ("b", 100), mark: point + // x Band ["a","b"], n=2, slot=280, bandW=252, pad=14 + // "b": px=354.0 + // y Linear: Continuous(0,100), niceTicks(0,100,5)=>step=50,nLo=0,nHi=100 + // apply(0)=440, apply(100)=20 + // Circle for "b": cx=354, cy=20, r=4 (default) + case class Row(x: String, y: Int) + val rows = Chunk(Row("a", 0), Row("b", 100)) + val spec = Chart(rows)(point(x = _.x, y = _.y)) + (spec).lower.map { root => + val circles = circlesIn(root) + assert(circles.size == 2, s"Expected 2 circles but got ${circles.size}") + // Point glyphs sit at the band CENTRE (left edge + bandW/2), aligning with the centred x-tick labels. + // (354.0 would be the band LEFT edge, half a band off from the tick.) + val slot = 280.0 + val bandW = 252.0 + val pad = (slot - bandW) / 2.0 + val px_b = PlotX + slot + pad + bandW / 2 // 480.0 (band centre) + val c = circles(1) // second circle (for row "b", y=100) + c.svgAttrs.cx match + case Present(v) => assertClose(v, px_b, "point cx") + case Absent => fail("Expected cx to be Present") + c.svgAttrs.cy match + case Present(v) => assertClose(v, 20.0, "point cy") + case Absent => fail("Expected cy to be Present") + c.svgAttrs.r match + case Present(v) => assertClose(v, 4.0, "point r") + case Absent => fail("Expected r to be Present") + } + } + + // ---- point circles carry a separating outline stroke ---- + // A filled point with no outline lets overlapping/adjacent bubbles merge into one blob. Each point + // circle must have BOTH a fill (the per-mark palette color) AND a stroke (the separating outline, + // the theme background color: white on the light theme) with a positive stroke width. + + "point circle has both a palette fill and a separating outline stroke (light theme background color)" in { + case class Row(x: String, y: Int) + val rows = Chunk(Row("a", 0), Row("b", 100)) + val spec = Chart(rows)(point(x = _.x, y = _.y)) + (spec).lower.map { root => + val circles = circlesIn(root) + assert(circles.nonEmpty, s"Expected at least one circle but got ${circles.size}") + circles.foldLeft(()): (_, c) => + // fill is the per-mark palette color: a single point mark is mark 0 -> palette(0) = blue. + c.svgAttrs.fill match + case Present(Svg.Paint.Color(col)) => + assert(col == Style.Color.blue, s"Point fill should be palette(0) (blue) but got $col") + case other => fail(s"Expected a point color fill but got $other") + end match + // stroke is the separating outline: the light theme background color (white). + c.svgAttrs.stroke match + case Present(Svg.Paint.Color(col)) => + assert(col == Style.Color.white, s"Point separating stroke should be the light background (white) but got $col") + case other => fail(s"Expected a point separating stroke color but got $other") + end match + assert(c.svgAttrs.strokeWidth.isDefined, "Point must have a positive stroke width for the separating outline") + } + } + + // ---- Test 5: rule at y=Const lowers to Svg.Line spanning the plot ---- + + "rule(y=Const(1000)) lowers to an Svg.Line spanning the plot at scaled y" in { + // Data: Sale("Jan", Usd(2000)); marks: bar + rule(y=1000) + // y extent: bar contributes Continuous(0,2000), rule contributes Continuous(1000,1000) + // merged: Continuous(0, 2000) + // niceTicks(0,2000,5) => step=500 => nLo=0, nHi=2000 + // Scale.Linear(0,2000,440,20): apply(1000) = 440 + 0.5*(20-440) = 230 + // Rule line: x1=60, y1=230, x2=620, y2=230 + val rows = Chunk(Sale("Jan", Usd(2000))) + val rv = RuleValue.Const(Usd(1000), summon[Plottable[Usd]]) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + rule[Sale, Usd](y = rv) + ) + (spec).lower.map { root => + val lines = linesIn(root) + assert(lines.size == 1, s"Expected 1 rule line but got ${lines.size}") + val l = lines(0) + l.svgAttrs.x1 match + case Present(v) => assertClose(v, PlotX, "rule x1") + case Absent => fail("Expected x1 to be Present") + l.svgAttrs.y1 match + case Present(v) => assertClose(v, 230.0, "rule y1") + case Absent => fail("Expected y1 to be Present") + l.svgAttrs.x2 match + case Present(v) => assertClose(v, PlotX + PlotW, "rule x2") + case Absent => fail("Expected x2 to be Present") + l.svgAttrs.y2 match + case Present(v) => assertClose(v, 230.0, "rule y2") + case Absent => fail("Expected y2 to be Present") + } + } + + // ---- Test 6: gap line splits into two sub-paths (two MoveTos) ---- + + "line with Absent y splits into two sub-paths (assert two MoveTos)" in { + // Rows: ("a", Present(100)), ("b", Absent), ("c", Present(200)) + // y type: Maybe[Int] with Plottable[Maybe[Int]] + // fromTotal wraps as: accessor(row) = Present(row.y) where row.y: Maybe[Int] + // In lowering: matches Present(yv) where yv = Present(100)|Absent|Present(200) + // toDomain called on Maybe[Int]: Present(100)->Continuous(100), Absent->Absent + // x Band ["a","b","c"], n=3, slot=560/3, bandW=560*0.9/3=168 + // "a": px = 60 + 0*(560/3) + (560/3 - 168)/2 + // "c": px = 60 + 2*(560/3) + (560/3 - 168)/2 + // y Linear: extent Continuous(100,200), niceTicks(100,200,5)=>step=50,nLo=100,nHi=200 + // apply(100)=440, apply(200)=20 + // PathData: MoveTo(px_a, 440), MoveTo(px_c, 20) -- two MoveTos, no LineTo + case class Row(x: String, y: Maybe[Int]) + val rows = Chunk(Row("a", Present(100)), Row("b", Absent), Row("c", Present(200))) + val spec = Chart(rows)(line(x = _.x, y = _.y)) + (spec).lower.map { root => + val paths = pathsIn(root) + assert(paths.size == 1, s"Expected 1 path but got ${paths.size}") + val cmds = paths(0).svgAttrs.d match + case Present(pd) => PathData.commands(pd) + case Absent => fail("Expected path to have d attribute") + // Count MoveTo commands + val moveToCount = cmds.count: + case PathCommand.MoveTo(_, _) => true + case _ => false + assert(moveToCount == 2, s"Expected 2 MoveTos (one per contiguous run) but got $moveToCount") + // Both commands should be MoveTos (no LineTo between gaps) + assert(cmds.size == 2, s"Expected exactly 2 path commands but got ${cmds.size}") + // Vertices sit at the band CENTRE (left edge + bandW/2), aligning with the centred x-tick labels. + // (The band LEFT edge would be half a band off from the tick.) + val slot = PlotW / 3.0 + val bandW = PlotW * 0.9 / 3.0 // 168.0 + val pad = (slot - bandW) / 2.0 + val px_a = PlotX + 0 * slot + pad + bandW / 2 + val px_c = PlotX + 2 * slot + pad + bandW / 2 + cmds(0) match + case PathCommand.MoveTo(x, y) => + assertClose(x, px_a, "gap MoveTo(a) x") + assertClose(y, 440.0, "gap MoveTo(a) y") + case other => fail(s"Expected MoveTo but got $other") + end match + cmds(1) match + case PathCommand.MoveTo(x, y) => + assertClose(x, px_c, "gap MoveTo(c) x") + assertClose(y, 20.0, "gap MoveTo(c) y") + case other => fail(s"Expected MoveTo but got $other") + end match + } + } + + // ---- Test 7: empty data lowers to valid Svg.Root with no mark shapes ---- + + "empty data lowers to a valid Svg.Root with no mark shapes" in { + val rows: Chunk[Sale] = Chunk.empty + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + (spec).lower.map { root => + assert(rectsIn(root).isEmpty, "Expected no rects for empty data") + assert(pathsIn(root).isEmpty, "Expected no paths for empty data") + assert(circlesIn(root).isEmpty, "Expected no circles for empty data") + assert(linesIn(root).isEmpty, "Expected no lines for empty data") + // Root itself is still valid + root.svgAttrs.width match + case Present(Coord.Num(w)) => assertClose(w, 640.0, "svg width") + case other => fail(s"Expected width Present(640.0) but got $other") + } + } + + // ---- Test 8: color encoding splits bar into N grouped sub-bands ---- + + "color encoding splits a bar into N grouped sub-bands (N rects per band)" in { + // Rows: 3 rows all with same x="Jan", 3 distinct regions => 3 rects + // band is subdivided: sub-band width = bandW / 3 = 504/3 = 168 + // NA: barX=88+0*168=88 + // EU: barX=88+1*168=256 + // APAC: barX=88+2*168=424 + val rows = Chunk( + Sale("Jan", Usd(1000), Region.NA), + Sale("Jan", Usd(2000), Region.EU), + Sale("Jan", Usd(1500), Region.APAC) + ) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue, color = _.region)) + (spec).lower.map { root => + val rects = rectsIn(root) + // 3 rows * 1 x-band / 3 colors = 3 rects (one per color sub-band) + assert(rects.size == 3, s"Expected 3 rects (one per color group) but got ${rects.size}") + // All rects should have the same sub-band width = 504/3 = 168 + val expectedSubW = 504.0 / 3.0 + rects.foreach: r => + assertClose(numOf(r.svgAttrs.width), expectedSubW, "sub-band width") + // X positions should be distinct and non-overlapping + val xs = rects.map(r => numOf(r.svgAttrs.x)).toSeq.sorted + assert(xs.size == 3) + assertClose(xs(0), 88.0, "first sub-band x") + assertClose(xs(1), 88.0 + expectedSubW, "second sub-band x") + assertClose(xs(2), 88.0 + 2 * expectedSubW, "third sub-band x") + } + } + + // ---- Test 8a: color 1:1 with x must NOT dodge; bars stay full-band and slot-centered ---- + // When the color encoding is 1:1 with the x encoding (each x-band contains exactly ONE color), + // grammar-of-graphics convention says color must NOT subdivide the band: it just paints full-width + // bars. Dodging every bar into a global color sub-slot keyed by global color index would put + // category 0 at the far left (width bandW/N), category 1 one slot right, etc., yielding thin bars + // marching left-to-right and misaligned with their centered x-axis tick labels. Instead, + // lowerBarGrouped renders simple full-band bars when no band holds more than one distinct color. + + "color encoding that is 1:1 with x does not dodge: bars stay full-band and slot-centered" in { + // 3 distinct categories A/B/C, each its OWN color (color == label). Each x-band holds exactly one + // color => max-distinct-colors-per-band = 1 => simple full-band bars, NOT N grouped sub-bands. + // x Band: n=3, totalW=560, slot=560/3=186.666..., bandW=560*0.9/3=168.0 + // A: bandX = 60 + 0*slot + (slot-bandW)/2 = 60 + 9.333... = 69.333... + // B: bandX = 60 + 1*slot + (slot-bandW)/2 = 256.0 + // C: bandX = 60 + 2*slot + (slot-bandW)/2 = 442.666... + case class Cat(label: String, value: Double) + given CanEqual[Cat, Cat] = CanEqual.derived + val rows = Chunk( + Cat("A", 1000.0), + Cat("B", 2000.0), + Cat("C", 1500.0) + ) + val spec = Chart(rows)(bar(x = _.label, y = _.value, color = _.label)) + (spec).lower.map { root => + val rects = rectsIn(root) + assert(rects.size == 3, s"Expected 3 rects (one per category) but got ${rects.size}") + + val slot = 560.0 / 3.0 + val expectedBandW = 560.0 * 0.9 / 3.0 // 168.0 + // Old (buggy) sub-band width would have been bandW/3 = 56.0; full-band is 3x that. + val oldSubW = expectedBandW / 3.0 + assertClose(expectedBandW, oldSubW * 3.0, "full band is 3x old sub-band width") + + // Every rect must be FULL band width (168), not the thin dodge width (56). + rects.foreach: r => + assertClose(numOf(r.svgAttrs.width), expectedBandW, "full-band width (must NOT be bandW/3)") + + // Each rect's x must be its band LEFT edge (slot-centered), NOT offset by colorIdx*subW. + val xsSorted = rects.map(r => numOf(r.svgAttrs.x)).toSeq.sorted + def bandLeft(i: Int) = 60.0 + i.toDouble * slot + (slot - expectedBandW) / 2.0 + assertClose(xsSorted(0), bandLeft(0), "A band left edge (69.333..)") + assertClose(xsSorted(1), bandLeft(1), "B band left edge (256.0)") + assertClose(xsSorted(2), bandLeft(2), "C band left edge (442.666..)") + } + } + + // ---- grouped bar with numeric color encoding honors a Sequential color scale ---- + // A non-stacked bar whose color encoding is NUMERIC plus `.legend(_.colorScaleSequential(low, high))` + // must paint each bar with the interpolated gradient color for its value, the same way lowerPoint and + // lowerArea do via resolvePalette. lowerBarGrouped must route the Sequential scale through + // resolvePalette rather than coloring bars from the categorical theme/DefaultPalette (blue/orange/...). + + "grouped bar with numeric color encoding honors a Sequential color scale (gradient, not categorical)" in { + // Three rows, same x="Jan", numeric color values 0.0/50.0/100.0 over domain extent [0, 100]. + // Sequential(black=#000000, white=#ffffff): value 0 => rgb(0,0,0), 50 => rgb(128,128,128), + // 100 => rgb(255,255,255). These are Style.Color.Rgb, NOT the categorical blue/orange Hex fills. + case class Heat(month: String, revenue: Double, level: Double) + given CanEqual[Heat, Heat] = CanEqual.derived + val rows = Chunk( + Heat("Jan", 1000.0, 0.0), + Heat("Jan", 2000.0, 50.0), + Heat("Jan", 1500.0, 100.0) + ) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue, color = _.level)) + .legend(_.colorScaleSequential(Style.Color.black, Style.Color.white)) + (spec).lower.map { root => + val rects = rectsIn(root) + assert(rects.size == 3, s"Expected 3 bar rects but got ${rects.size}") + + // The grouped bars are positioned left-to-right by color-category index (encounter order: + // level 0.0, 50.0, 100.0). Order rects by x to recover that mapping. + val byX = rects.toSeq.sortBy(r => numOf(r.svgAttrs.x)) + def fillOf(r: Svg.Rect): Style.Color = + r.svgAttrs.fill match + case Present(Svg.Paint.Color(c)) => c + case other => fail(s"Expected a color fill but got $other") + val fills = byX.map(fillOf) + + // Interpolated sequential fills, NOT the categorical palette. + assert(fills(0) == Style.Color.rgb(0, 0, 0), s"lowest-value bar must be the low color; got ${fills(0)}") + assert(fills(1) == Style.Color.rgb(128, 128, 128), s"mid-value bar must be midpoint gradient; got ${fills(1)}") + assert(fills(2) == Style.Color.rgb(255, 255, 255), s"highest-value bar must be the high color; got ${fills(2)}") + + // Must NOT be the categorical DefaultPalette/theme colors. + assert( + fills.forall(c => c != Style.Color.blue && c != Style.Color.orange), + s"bar fills must not be categorical palette colors (#3b82f6/#f97316); got $fills" + ) + } + } + + // ---- grouped bar with a Categorical colorScale uses the scale colors ---- + // lowerBarGrouped's `palette` val routes any `Present(_)` colorScale through resolvePalette, so both + // Categorical and Sequential colorScales are honoured. A `Present(_: Categorical)` must not fall + // through to the by-index basePalette, which would give DefaultPalette colors (#3b82f6 blue, + // #f97316 orange, ...) instead of the colorScale colors. + + "grouped bar with categorical colorScale uses the scale colors, not DefaultPalette" in { + // Three rows sharing x="Jan", one per region -- this guarantees dodge=true (multiple distinct + // colors in the same x-band). Colors are chosen to be unambiguously distinct from the + // DefaultPalette entries (#3b82f6 blue and #f97316 orange) so an accidental fallback is caught. + val naColor = Style.Color.hex("#e63946").getOrElse(fail("bad hex naColor")) // red + val euColor = Style.Color.hex("#2a9d8f").getOrElse(fail("bad hex euColor")) // teal + val apacColor = Style.Color.hex("#e9c46a").getOrElse(fail("bad hex apacColor")) // yellow + val rows = Chunk( + Sale("Jan", Usd(1000), Region.NA), + Sale("Jan", Usd(2000), Region.EU), + Sale("Jan", Usd(1500), Region.APAC) + ) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue, color = _.region)) + .legend(_.colorScale[Region]( + Region.NA -> naColor, + Region.EU -> euColor, + Region.APAC -> apacColor + )) + (spec).lower.map { root => + val rects = rectsIn(root) + assert(rects.size == 3, s"Expected 3 bar rects (one per region) but got ${rects.size}") + + // Sort rects by x position to recover NA/EU/APAC encounter order. + val byX = rects.toSeq.sortBy(r => numOf(r.svgAttrs.x)) + def fillOf(r: Svg.Rect): Style.Color = + r.svgAttrs.fill match + case Present(Svg.Paint.Color(c)) => c + case other => fail(s"Expected a color fill but got $other") + val fills = byX.map(fillOf) + + // Each sub-bar must carry the colorScale color for its region, NOT the DefaultPalette color. + // colorCats are collected in encounter order: NA (idx 0), EU (idx 1), APAC (idx 2). + assert(fills(0) == naColor, s"NA sub-bar (idx 0) must be naColor $naColor but got ${fills(0)}") + assert(fills(1) == euColor, s"EU sub-bar (idx 1) must be euColor $euColor but got ${fills(1)}") + assert(fills(2) == apacColor, s"APAC sub-bar (idx 2) must be apacColor $apacColor but got ${fills(2)}") + + // Explicit guard: must NOT be the DefaultPalette fallback colors. + assert( + fills.forall(c => c != Style.Color.blue && c != Style.Color.orange), + s"bar fills must not be DefaultPalette colors (#3b82f6/#f97316); got $fills" + ) + + // Legend swatch colors must agree with their corresponding mark fills. + // Swatches are 12x12 rects that are direct children of the root frame (not inside the marks G). + val swatches = legendSwatchRects(root) + assert(swatches.size == 3, s"Expected 3 legend swatches but got ${swatches.size}") + // Sort swatches by y position (they are stacked vertically in encounter order: NA, EU, APAC). + val swatchesByY = swatches.toSeq.sortBy(s => coordNum(s.svgAttrs.y).getOrElse(0.0)) + val swatchFills = swatchesByY.map(s => fillColorOf(s.svgAttrs.fill)) + assert(swatchFills(0) == naColor, s"Legend swatch 0 must be naColor $naColor but got ${swatchFills(0)}") + assert(swatchFills(1) == euColor, s"Legend swatch 1 must be euColor $euColor but got ${swatchFills(1)}") + assert(swatchFills(2) == apacColor, s"Legend swatch 2 must be apacColor $apacColor but got ${swatchFills(2)}") + } + } + + // ---- Test 9: line path has fill=None, stroke present, strokeWidth present ---- + // Verifies that lowerLineSeries emits fill=None and a stroke, so the path renders as a stroked line + // rather than a filled black polygon (a path with no paint is filled black by the browser default). + + "line mark lowers to a path with fill=Paint.None, stroke present, and strokeWidth present" in { + case class Row(x: String, y: Int) + val rows = Chunk(Row("a", 100), Row("b", 200)) + val spec = Chart(rows)(line(x = _.x, y = _.y)) + (spec).lower.map { root => + val ps = pathsIn(root) + assert(ps.size == 1, s"Expected 1 path but got ${ps.size}") + val p = ps(0) + // fill must be explicitly Paint.None to suppress browser default (black) fill + assert( + p.svgAttrs.fill == Present(Svg.Paint.None), + s"Expected fill=Paint.None but got ${p.svgAttrs.fill}" + ) + // stroke must be present so the line is visible + assert(p.svgAttrs.stroke.isDefined, s"Expected stroke to be Present but got ${p.svgAttrs.stroke}") + // strokeWidth must be present + assert(p.svgAttrs.strokeWidth.isDefined, s"Expected strokeWidth to be Present but got ${p.svgAttrs.strokeWidth}") + } + } + + // ---- Test 10: bar rect is filled (not just stroked) ---- + // Distinguishes bar from line: a bar should have an explicit fill color set, not fill=None. + + "bar mark lowers to a rect with a non-None fill color" in { + val rows = Chunk(Sale("Jan", Usd(1000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + (spec).lower.map { root => + val rects = rectsIn(root) + assert(rects.size == 1, s"Expected 1 rect but got ${rects.size}") + val r = rects(0) + r.svgAttrs.fill match + case Present(Svg.Paint.Color(_)) => succeed // correct: a color fill, not None + case Present(Svg.Paint.None) => fail("Bar fill must not be Paint.None: bars are filled shapes") + case other => fail(s"Expected a color fill but got $other") + end match + } + } + + // ---- Test 10b: single-mark default color is palette(0) ---- + // A chart with a single mark and no explicit color encoding uses the first palette entry (blue). + + "single-mark bar uses palette(0) (blue) as its default fill" in { + val rows = Chunk(Sale("Jan", Usd(1000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + (spec).lower.map { root => + val rects = rectsIn(root) + assert(rects.size == 1, s"Expected 1 rect but got ${rects.size}") + rects(0).svgAttrs.fill match + case Present(Svg.Paint.Color(c)) => + assert(c == Style.Color.blue, s"Single-mark bar should use palette(0) (blue) but got $c") + case other => fail(s"Expected a color fill but got $other") + end match + } + } + + // ---- multi-mark combo assigns DISTINCT palette colors per mark index ---- + // In a combo chart (bar + line, both without an explicit color encoding) mark 0 (bar) must use + // palette(0) (blue) and mark 1 (line) must use palette(1) (orange), so the line is visually + // distinguishable from the bars rather than sharing the same default color. + + "combo (bar + line) assigns distinct per-mark palette colors: bar=palette(0), line=palette(1)" in { + case class Combo(month: String, revenue: Double, growth: Double) + given CanEqual[Combo, Combo] = CanEqual.derived + val rows = Chunk(Combo("Jan", 1000.0, 10.0), Combo("Feb", 2000.0, 20.0)) + val spec = Chart(rows)( + bar(x = _.month, y = _.revenue), + line(x = _.month, y = _.growth, axis = Axis.Right) + ) + (spec).lower.map { root => + // Bar mark (index 0): fill must be palette(0) = blue. + val rects = rectsIn(root) + assert(rects.nonEmpty, s"Expected at least one bar rect but got ${rects.size}") + rects.foreach: r => + r.svgAttrs.fill match + case Present(Svg.Paint.Color(c)) => + assert(c == Style.Color.blue, s"Bar (mark 0) should be palette(0) (blue) but got $c") + case other => fail(s"Expected a bar color fill but got $other") + + // Line mark (index 1): stroke must be palette(1) = orange (distinct from the bar's blue). + val paths = pathsIn(root) + assert(paths.size == 1, s"Expected 1 line path but got ${paths.size}") + paths(0).svgAttrs.stroke match + case Present(Svg.Paint.Color(c)) => + assert(c == Style.Color.orange, s"Line (mark 1) should be palette(1) (orange) but got $c") + assert(c != Style.Color.blue, "Line color must differ from the bar color in a combo chart") + case other => fail(s"Expected a line stroke color but got $other") + end match + } + } + + // ---- Test 11: dark-theme bar uses a light/visible fill color ---- + // Verifies dark-theme bars use a visible fill color rather than the browser-default black, + // which would be invisible on the dark (#1f2937) background. + + "dark-theme bar uses a light fill color, not black" in { + val rows = Chunk(Sale("Jan", Usd(1000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)).theme(_.dark) + (spec).lower.map { root => + val rects = rectsIn(root) + assert(rects.size == 1, s"Expected 1 rect but got ${rects.size}") + val r = rects(0) + r.svgAttrs.fill match + case Present(Svg.Paint.Color(c)) => + // Must NOT be black (which is the browser default that made bars invisible on dark bg) + assert( + c != Style.Color.black, + s"Dark-theme bar fill must not be black; got $c" + ) + case Present(Svg.Paint.None) => fail("Dark-theme bar fill must not be None") + case other => fail(s"Expected a color fill but got $other") + end match + } + } + + // ---- dark-theme axis text colors ---- + // On the dark theme the SVG background is dark, so axis text must not be the browser default black. + // The shared x-axis chrome stays the neutral light gray (#e5e7eb). The left y-axis is bound to exactly + // one mark (the single bar = mark 0), so the axis color-codes its tick labels to that mark's palette + // color (palette(0) = blue), not the neutral gray. + + "dark-theme axis text: x-axis stays neutral light gray, single-mark y-axis is color-coded (never black)" in { + // Neutral light text color used on the dark theme (must match ChartLower.DarkThemeTextColor). + val lightText = Style.Color.hex("#e5e7eb").getOrElse(Style.Color.white) + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)) + .yAxis(_.ticks(3)) + .xAxis(_.label("Month")) + .theme(_.dark) + (spec).lower.map { root => + // Frame texts (tick labels and the x-axis label) live directly under the root. + val texts = root.children.flatMap: + case t: Svg.Text => Chunk(t) + case _ => Chunk.empty + assert(texts.nonEmpty, "Expected axis text elements on the dark theme") + + // No axis text may be black on the dark theme. + texts.foldLeft(()): (_, t) => + t.svgAttrs.fill match + case Present(Svg.Paint.Color(c)) => assert(c != Style.Color.black, "Dark-theme axis text must not be black") + case other => fail(s"Expected a fill color on dark-theme axis text but got $other") + + // x-axis chrome stays the neutral light gray: x tick labels (DominantBaseline.Hanging) and the + // bottom "Month" label (TextAnchor.Middle, no rotation). + val xTexts = texts.filter(t => + t.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Hanging) || + t.children.exists { case UI.Ast.Text("Month") => true; case _ => false } + ) + assert(xTexts.nonEmpty, "Expected x-axis tick labels and/or the Month label") + xTexts.foldLeft(()): (_, t) => + t.svgAttrs.fill match + case Present(Svg.Paint.Color(c)) => assert(c == lightText, s"X-axis text should stay neutral $lightText but got $c") + case other => fail(s"Expected an x-axis text fill but got $other") + + // Left y-axis tick labels (TextAnchor.End) are color-coded to the single bound mark: palette(0) = blue. + val leftTicks = texts.filter(t => t.svgAttrs.textAnchor.contains(Svg.TextAnchor.End)) + assert(leftTicks.nonEmpty, "Expected left y-axis tick labels") + leftTicks.foldLeft(()): (_, t) => + t.svgAttrs.fill match + case Present(Svg.Paint.Color(c)) => + assert(c == Style.Color.blue, s"Single-mark y-axis tick should be palette(0) (blue) but got $c") + case other => fail(s"Expected a left y-axis tick fill but got $other") + } + } + + // ---- dark-theme background covers the whole SVG canvas ---- + // The background rect must span the entire SVG (not only the plot rect) so the axis margins read dark. + + "dark-theme background rect covers the whole SVG canvas" in { + val rows = Chunk(Sale("Jan", Usd(1000))) + val spec = Chart(rows)(bar(x = _.month, y = _.revenue)).theme(_.dark).size(360, 240) + (spec).lower.map { root => + val bg = root.children.collectFirst { case r: Svg.Rect => r }.getOrElse(fail("Expected a background rect")) + assertClose(numOf(bg.svgAttrs.x), 0.0, "background x") + assertClose(numOf(bg.svgAttrs.y), 0.0, "background y") + assertClose(numOf(bg.svgAttrs.width), 360.0, "background width spans full SVG") + assertClose(numOf(bg.svgAttrs.height), 240.0, "background height spans full SVG") + } + } + + // ---- point color / symbol / size / curve / area band / stacks / encodings ---- + + case class Row(x: String, y: Double, g: String, mag: Double = 1.0, name: String = "") + given CanEqual[Row, Row] = CanEqual.derived + + private def deepCirclesIn(root: Svg.Root): Chunk[Svg.Circle] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case c: Svg.Circle => Chunk(c) + case gg: Svg.G => + gg.children.flatMap: + case c: Svg.Circle => Chunk(c) + case _ => Chunk.empty + case _ => Chunk.empty + case _ => Chunk.empty + + private def deepPathsIn(root: Svg.Root): Chunk[Svg.Path] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case p: Svg.Path => Chunk(p) + case gg: Svg.G => + gg.children.flatMap: + case p: Svg.Path => Chunk(p) + case _ => Chunk.empty + case _ => Chunk.empty + case _ => Chunk.empty + + private def deepTextsIn(root: Svg.Root): Chunk[Svg.Text] = + root.children.flatMap: + case g: Svg.G => + g.children.flatMap: + case t: Svg.Text => Chunk(t) + case gg: Svg.G => + gg.children.flatMap: + case t: Svg.Text => Chunk(t) + case _ => Chunk.empty + case _ => Chunk.empty + case _ => Chunk.empty + + private def fillColorOf(c: Svg.Circle): String = c.svgAttrs.fill match + case Present(Svg.Paint.Color(col)) => col.toString + case other => s"unexpected:$other" + + private def fillColorOfPath(p: Svg.Path): String = p.svgAttrs.fill match + case Present(Svg.Paint.Color(col)) => col.toString + case other => s"unexpected:$other" + + private def hasCubicCmd(p: Svg.Path): Boolean = + Svg.PathData.commands(p.svgAttrs.d.getOrElse(Svg.PathData.empty)).toSeq.exists: + case _: PathCommand.CubicTo => true + case _ => false + + private def cubicCount(p: Svg.Path): Int = + Svg.PathData.commands(p.svgAttrs.d.getOrElse(Svg.PathData.empty)).toSeq.count: + case _: PathCommand.CubicTo => true + case _ => false + + private def hasHLineCmd(p: Svg.Path): Boolean = + Svg.PathData.commands(p.svgAttrs.d.getOrElse(Svg.PathData.empty)).toSeq.exists: + case _: PathCommand.HLineTo => true + case _ => false + + private def hasVLineCmd(p: Svg.Path): Boolean = + Svg.PathData.commands(p.svgAttrs.d.getOrElse(Svg.PathData.empty)).toSeq.exists: + case _: PathCommand.VLineTo => true + case _ => false + + private def hasCloseCmd(p: Svg.Path): Boolean = + Svg.PathData.commands(p.svgAttrs.d.getOrElse(Svg.PathData.empty)).toSeq.exists: + case PathCommand.Close => true + case _ => false + + // --- point color splits --- + "point color splits into per-group colors" in { + val rows = Chunk(Row("a", 10.0, "g1"), Row("b", 20.0, "g2")) + val spec = Chart(rows)(point(x = _.x, y = _.y, color = _.g)) + (spec).lower.map { root => + val cs = deepCirclesIn(root) + assert(cs.size >= 2, s"Expected at least 2 circles, got ${cs.size}") + val fills = cs.map(fillColorOf).toSeq.distinct + assert(fills.size == 2, s"Expected 2 distinct fill colors for 2 groups, got $fills") + } + } + + // --- CatKey identity: two enum cases with colliding toString produce distinct color groups --- + "point color uses CatKey identity: colliding-toString enum cases produce distinct fills" in { + enum Collision derives CanEqual: + case X, Y + override def toString: String = "same" + case class RawRow(x: String, y: Double, grp: Collision) + given CanEqual[RawRow, RawRow] = CanEqual.derived + val rows = Chunk(RawRow("a", 10.0, Collision.X), RawRow("b", 20.0, Collision.Y)) + val spec = Chart(rows)(point(x = _.x, y = _.y, color = _.grp)) + spec.lower.map { root => + val cs = deepCirclesIn(root) + val fills = cs.map(fillColorOf).toSeq.distinct + assert(fills.size == 2, s"Two colliding-toString enum cases must produce two distinct fills, got $fills") + } + } + + // --- no color keeps defaultColor --- + "point without color encoding: all circles have the same default fill" in { + val rows = Chunk(Row("a", 10.0, "g1"), Row("b", 20.0, "g2")) + val spec = Chart(rows)(point(x = _.x, y = _.y)) + (spec).lower.map { root => + val cs = deepCirclesIn(root) + assert(cs.size >= 2, s"Expected at least 2 circles") + val fills = cs.map(fillColorOf).toSeq.distinct + assert(fills.size == 1, s"All circles without color encoding should share one fill, got $fills") + } + } + + // --- symbol=square renders Svg.Path not Svg.Circle --- + "symbol=square renders Svg.Path elements, not circles" in { + val rows = Chunk(Row("a", 10.0, "g1"), Row("b", 20.0, "g2")) + val spec = Chart(rows)(point(x = _.x, y = _.y, symbol = _ => Symbol.square)) + (spec).lower.map { root => + val cs = deepCirclesIn(root) + val ps = deepPathsIn(root).filter(p => p.svgAttrs.d.isDefined && p.svgAttrs.fill.isDefined) + assert(cs.isEmpty, s"symbol=square must not emit Svg.Circle, but got ${cs.size}") + assert(ps.nonEmpty, s"symbol=square must emit Svg.Path elements") + } + } + + // --- each Symbol case --- + "triangle, diamond, cross each render their documented glyph" in { + val rows = Chunk(Row("a", 10.0, "g")) + // Triangle and diamond should produce Svg.Path. + for sym <- Seq(Symbol.triangle, Symbol.diamond) do + val spec = Chart(rows)(point(x = _.x, y = _.y, symbol = _ => sym)) + (spec).lower.map { root => + val ps = deepPathsIn(root).filter(p => p.svgAttrs.d.isDefined) + val cs = deepCirclesIn(root) + assert(ps.nonEmpty, s"$sym must emit Svg.Path") + assert(cs.isEmpty, s"$sym must not emit Svg.Circle") + } + end for + // Circle default. + val specC = Chart(rows)(point(x = _.x, y = _.y)) + (specC).lower.map { rootC => + assert(deepCirclesIn(rootC).nonEmpty, "Default symbol (circle) must emit Svg.Circle") + } + // Cross produces Svg.Line elements (two strokes). + val specX = Chart(rows)(point(x = _.x, y = _.y, symbol = _ => Symbol.cross)) + (specX).lower.map { rootX => + val ls = linesIn(rootX) + assert(ls.nonEmpty, "Symbol.cross must emit Svg.Line strokes") + } + } + + // --- size is sqrt-area scaled --- + "point size is sqrt-area scaled: bigger magnitude produces bigger radius" in { + case class Bubble(x: Double, y: Double, mag: Double) + given CanEqual[Bubble, Bubble] = CanEqual.derived + val rows = Chunk(Bubble(1.0, 1.0, 1.0), Bubble(2.0, 1.0, 100.0)) + val spec = Chart(rows)(point(x = _.x, y = _.y, size = _.mag)) + (spec).lower.map { root => + val cs = deepCirclesIn(root) + assert(cs.size == 2, s"Expected 2 circles, got ${cs.size}") + val rs = cs.map(c => c.svgAttrs.r).toSeq.collect: + case Present(v) => v + assert(rs.size == 2, s"Both circles must have a radius, got $rs") + assert(rs(0) < rs(1), s"Larger magnitude should produce larger radius: ${rs(0)} vs ${rs(1)}") + // Verify sqrt-area: r(1) should be ~2.0, r(100) should be ~20.0. + assert(math.abs(rs(0) - 2.0) < 1e-6, s"r(mag=1) should be rMin=2.0, got ${rs(0)}") + assert(math.abs(rs(1) - 20.0) < 1e-6, s"r(mag=100) should be rMax=20.0, got ${rs(1)}") + } + } + + // --- equal magnitudes yield rMin, no div-by-zero --- + "equal magnitudes yield rMin, no div-by-zero" in { + case class Bubble(x: Double, y: Double, mag: Double) + given CanEqual[Bubble, Bubble] = CanEqual.derived + val rows = Chunk(Bubble(1.0, 1.0, 5.0), Bubble(2.0, 1.0, 5.0)) + val spec = Chart(rows)(point(x = _.x, y = _.y, size = _.mag)) + (spec).lower.map { root => + val cs = deepCirclesIn(root) + assert(cs.size == 2, "Expected 2 circles") + val badRadii = cs.toSeq.filter: c => + c.svgAttrs.r.map(v => math.abs(v - 2.0) >= 1e-6).getOrElse(true) + assert(badRadii.isEmpty, s"All circles with equal magnitudes should have rMin=2.0; bad: ${badRadii.map(_.svgAttrs.r)}") + } + } + + // --- size legend is emitted --- + "size legend is emitted when size encoding is set" in { + case class Bubble(x: Double, y: Double, mag: Double) + given CanEqual[Bubble, Bubble] = CanEqual.derived + val rows = Chunk(Bubble(1.0, 1.0, 1.0), Bubble(2.0, 1.0, 100.0)) + val spec = Chart(rows)(point(x = _.x, y = _.y, size = _.mag)) + (spec).lower.map { root => + // The legend region should contain circle elements (size bubbles). + val allCircles = root.children.flatMap: + case g: Svg.G => g.children.flatMap: + case c: Svg.Circle => Chunk(c) + case _ => Chunk.empty + case _ => Chunk.empty + assert(allCircles.nonEmpty, "Size legend should emit circle elements") + } + } + + // --- size wins over sizePx --- + "size wins over sizePx when both supplied: Mark.Point.size is Present, sizePx is Absent" in { + case class Bubble(x: Double, y: Double, mag: Double) + given CanEqual[Bubble, Bubble] = CanEqual.derived + val m = point[Bubble, Double, Double, Nothing](x = _.x, y = _.y, size = _.mag, sizePx = _ => 8.0) + val pm = m.asInstanceOf[Mark.Point[Bubble, Double, Double]] + assert(pm.size.isDefined, "size encoding must be Present when both size and sizePx supplied") + assert(!pm.sizePx.isDefined, "sizePx must be Absent when size wins") + } + + // --- sizePx alone uses raw radius --- + "sizePx alone uses raw pixel radius" in { + case class Bubble(x: Double, y: Double) + given CanEqual[Bubble, Bubble] = CanEqual.derived + val rows = Chunk(Bubble(1.0, 1.0), Bubble(2.0, 2.0)) + val spec = Chart(rows)(point(x = _.x, y = _.y, sizePx = _ => 8.0)) + (spec).lower.map { root => + val cs = deepCirclesIn(root) + assert(cs.size == 2, s"Expected 2 circles, got ${cs.size}") + val badR = cs.toSeq.filter(c => c.svgAttrs.r.map(v => math.abs(v - 8.0) >= 1e-6).getOrElse(true)) + assert(badR.isEmpty, s"sizePx=8.0 should yield r=8.0 for all circles; bad: ${badR.map(_.svgAttrs.r)}") + } + } + + // Tests 11-15 use (Double, Double) rows, relying on Plottable.numeric for Double. + // To avoid local-case-class implicit-scope issues, use the existing Row type with numeric x. + case class DRow(x: Double, y: Double) + given CanEqual[DRow, DRow] = CanEqual.derived + + case class DRowMaybe(x: Double, y: Maybe[Double]) + given CanEqual[DRowMaybe, DRowMaybe] = CanEqual.derived + + // --- curve=stepAfter emits H/V staircase --- + "curve=stepAfter line emits HLineTo and VLineTo commands" in { + val rows = Chunk(DRow(0.0, 0.0), DRow(1.0, 2.0), DRow(2.0, 1.0)) + val spec = Chart(rows)(line(x = _.x, y = _.y, curve = Curve.stepAfter)) + (spec).lower.map { root => + val ps = pathsIn(root) + assert(ps.nonEmpty, "Expected at least one path for line mark") + val hasH = ps.toSeq.exists(hasHLineCmd) + val hasV = ps.toSeq.exists(hasVLineCmd) + assert(hasH, "stepAfter line must emit HLineTo commands") + assert(hasV, "stepAfter line must emit VLineTo commands") + } + } + + // --- curve=monotone emits cubics --- + "curve=monotone line emits CubicTo commands" in { + val rows = Chunk(DRow(0.0, 0.0), DRow(1.0, 2.0), DRow(2.0, 1.0)) + val spec = Chart(rows)(line(x = _.x, y = _.y, curve = Curve.monotone)) + (spec).lower.map { root => + val ps = pathsIn(root) + assert(ps.nonEmpty, "Expected at least one path") + assert(ps.toSeq.exists(hasCubicCmd), "monotone line must emit CubicTo commands") + } + } + + // --- basis and catmullRom emit cubics --- + "curve=basis and catmullRom emit CubicTo commands" in { + val rows4 = Chunk(DRow(0.0, 0.0), DRow(1.0, 2.0), DRow(2.0, 0.0), DRow(3.0, 2.0)) + val basSpec = Chart(rows4)(line(x = _.x, y = _.y, curve = Curve.basis)) + val catSpec = Chart(rows4)(line(x = _.x, y = _.y, curve = Curve.catmullRom)) + for + basRoot <- (basSpec).lower + catRoot <- (catSpec).lower + yield + assert(pathsIn(basRoot).toSeq.exists(hasCubicCmd), "basis line must emit CubicTo commands") + assert(pathsIn(catRoot).toSeq.exists(hasCubicCmd), "catmullRom line must emit CubicTo commands") + end for + } + + // --- curve applies per-segment, not across gaps --- + "curve=monotone with gap: path has two MoveTo segments" in { + val rows = Chunk(DRowMaybe(0.0, Present(0.0)), DRowMaybe(1.0, Absent), DRowMaybe(2.0, Present(1.0))) + val spec = Chart(rows)(line(x = _.x, y = _.y, curve = Curve.monotone)) + (spec).lower.map { root => + val ps = pathsIn(root) + assert(ps.nonEmpty, "Expected at least one path") + val moveTos = ps.flatMap: p => + Svg.PathData.commands(p.svgAttrs.d.getOrElse(Svg.PathData.empty)).filter: + case PathCommand.MoveTo(_, _) => true + case _ => false + assert(moveTos.size >= 2, s"Gap must produce at least 2 MoveTo commands, got ${moveTos.size}") + } + } + + // --- fewer than 2 points degrades to linear --- + "curve=basis with 1 point: no cubic emitted" in { + val rows = Chunk(DRow(1.0, 2.0)) + val spec = Chart(rows)(line(x = _.x, y = _.y, curve = Curve.basis)) + (spec).lower.map { root => + val ps = pathsIn(root) + assert(!ps.toSeq.exists(hasCubicCmd), "1-point line must not emit cubics") + } + } + + // --- area band ribbon --- + "area y0/y1 band renders a non-empty closed ribbon" in { + case class Band(t: Double, lo: Double, hi: Double) + given CanEqual[Band, Band] = CanEqual.derived + val rows = Chunk(Band(0.0, 0.0, 2.0), Band(1.0, 0.5, 2.5)) + val spec = Chart(rows)(area(x = _.t, y0 = _.lo, y1 = _.hi)) + (spec).lower.map { root => + val ps = deepPathsIn(root).filter(p => p.svgAttrs.d.isDefined && hasCloseCmd(p)) + // Exactly one closed ribbon path: the band form always produces a single continuous path. + assert(ps.size == 1, s"area y0/y1 band must emit exactly 1 closed ribbon path but got ${ps.size}") + } + } + + // --- invalid area combo emits empty, siblings render --- + "area with only y1 (no y0, no y): mark emits empty, bar sibling renders" in { + case class Datum(x: String, y1: Double, y: Double) + given CanEqual[Datum, Datum] = CanEqual.derived + val rows = Chunk(Datum("a", 2.0, 1.0), Datum("b", 3.0, 2.0)) + // area with only y1 supplied: invalid combo -> empty mark. + val spec = Chart(rows)(area(x = _.x, y1 = _.y1), bar(x = _.x, y = _.y)) + (spec).lower.map { root => + val rs = rectsIn(root) + assert(rs.nonEmpty, "bar sibling must still render when area mark is invalid") + // The invalid area mark must have emitted no path elements at all. + assert(pathsIn(root).isEmpty, "area with only y1 (invalid combo) must emit no path elements") + } + } + + // --- single y wins over y+y0/y1 --- + "area with y and y0/y1 both supplied: single y wins" in { + case class Band(t: Double, v: Double, lo: Double, hi: Double) + given CanEqual[Band, Band] = CanEqual.derived + val rows = Chunk(Band(0.0, 1.0, 0.0, 2.0), Band(1.0, 1.5, 0.5, 2.5)) + val spec = Chart(rows)(area(x = _.t, y = _.v, y0 = _.lo, y1 = _.hi)) + (spec).lower.map { root => + // When y is supplied, the factory sets yMaybe=Present, so area renders the single-y form. + val ps = deepPathsIn(root).filter(_.svgAttrs.d.isDefined) + // Exactly 1 path: the y encoding wins, so a single series path is emitted (not a y0/y1 ribbon). + assert(ps.size == 1, s"area with single y must render exactly 1 path but got ${ps.size}") + // The single-y linear form does not emit CubicTo commands. The band form (y0+y1+curve) would emit + // cubics on both edges; the linear single-y path only has MoveTo/LineTo/Close. This distinguishes + // single-y from a band-with-curve path. + assert( + !ps.toSeq.exists(hasCubicCmd), + "area single-y linear form must not contain CubicTo commands (those appear in the band+curve form)" + ) + } + } + + // --- curve applies to BOTH area band edges --- + "area y0/y1 band with curve=monotone emits cubics on both edges" in { + case class Band(t: Double, lo: Double, hi: Double) + given CanEqual[Band, Band] = CanEqual.derived + val rows = Chunk(Band(0.0, 0.0, 2.0), Band(1.0, 0.5, 2.5), Band(2.0, 0.2, 1.8), Band(3.0, 0.6, 2.2)) + val spec = Chart(rows)(area(x = _.t, y0 = _.lo, y1 = _.hi, curve = Curve.monotone)) + (spec).lower.map { root => + val ps = deepPathsIn(root).filter(p => p.svgAttrs.d.isDefined && hasCloseCmd(p)) + assert(ps.nonEmpty, "area y0/y1 with monotone curve must render a closed path") + // Both the forward y1 edge AND the backward y0 edge must be curved. append emits one cubic per + // interior segment: for 4 band points each edge feeds 3 points to append (after dropping the + // anchor/connecting vertex), so each curved edge yields 2 cubics. A y1-only curve with a linear + // y0 edge would yield only 2 cubics total; requiring >=4 proves the y0 edge is also curved. + val ribbon = ps.toSeq.maxBy(cubicCount) + assert( + cubicCount(ribbon) >= 4, + s"both band edges must be curved (>=4 cubics: 2 per edge for 4 points); got ${cubicCount(ribbon)}" + ) + } + } + + // --- stacked bar with negative value: non-negative rect height --- + "stacked bar with negative value has non-negative rect height" in { + val rows = Chunk(Row("a", 10.0, "pos"), Row("a", -5.0, "neg")) + val spec = Chart(rows)(bar(x = _.x, y = _.y, stack = by(_.g))) + (spec).lower.map { root => + val rs = rectsIn(root) + assert(rs.nonEmpty, "Expected stacked bar rects") + val negH = rs.toSeq.filter: r => + r.svgAttrs.height match + case Present(Coord.Num(v)) => v < 0.0 + case _ => false + assert(negH.isEmpty, s"All rect heights must be non-negative; negatives: ${negH.map(_.svgAttrs.height)}") + } + } + + // --- negative stack does not clip positive segments --- + "stacked bar with mixed signs: positive and negative stacks both render" in { + val rows = Chunk(Row("a", 10.0, "pos"), Row("a", -5.0, "neg")) + val spec = Chart(rows)(bar(x = _.x, y = _.y, stack = by(_.g))) + (spec).lower.map { root => + val rs = rectsIn(root) + // Both positive and negative groups should emit a rect (non-zero rawY). + assert(rs.size >= 2, s"Expected rects for both positive and negative groups, got ${rs.size}") + } + } + + // --- all-positive stack renders rects --- + "all-positive stacked bar renders non-empty rects" in { + val rows = Chunk(Row("a", 10.0, "g1"), Row("a", 5.0, "g2")) + val spec = Chart(rows)(bar(x = _.x, y = _.y, stack = by(_.g))) + (spec).lower.map { root => + val rs = rectsIn(root) + assert(rs.size == 2, s"Expected 2 rects for all-positive stack, got ${rs.size}") + val badH = rs.toSeq.filter: r => + r.svgAttrs.height match + case Present(Coord.Num(v)) => v <= 0.0 + case _ => true + assert(badH.isEmpty, s"All-positive stack rects must have positive height; bad: ${badH.map(_.svgAttrs.height)}") + } + } + + // --- opacity encoding --- + "opacity encoding: bar fills are clamped to [0,1] fill-opacity" in { + val rows = Chunk(Row("a", 10.0, "g", 1.0), Row("b", 20.0, "g", 1.0)) + val spec = Chart(rows)(bar(x = _.x, y = _.y, opacity = r => if r.x == "a" then 0.5 else 1.7)) + (spec).lower.map { root => + val rs = rectsIn(root) + assert(rs.nonEmpty, "Expected rects") + val opacities = rs.toSeq.flatMap(r => r.svgAttrs.fillOpacity.toOption) + assert(opacities.nonEmpty, "Expected fillOpacity set on at least one rect") + assert(opacities.forall(v => v >= 0.0 && v <= 1.0), s"All fill-opacity values must be in [0,1], got $opacities") + // Verify clamping: the 1.7 value must be clamped to 1.0. + assert(opacities.exists(v => math.abs(v - 0.5) < 1e-9), "Expected fillOpacity=0.5 for first bar") + assert(opacities.exists(v => math.abs(v - 1.0) < 1e-9), "Expected fillOpacity clamped to 1.0 for out-of-range value") + } + } + + // --- label encoding --- + "label encoding: bar emits per-datum Svg.Text elements" in { + val rows = Chunk(Row("a", 10.0, "g"), Row("b", 20.0, "g")) + val spec = Chart(rows)(bar(x = _.x, y = _.y, label = r => r.y.toString)) + (spec).lower.map { root => + val ts = deepTextsIn(root) + assert(ts.nonEmpty, "label encoding must emit Svg.Text elements per bar") + assert(ts.size >= 2, s"Expected at least 2 text labels for 2 bars, got ${ts.size}") + } + } + + // --- tooltip encoding --- + "tooltip encoding: point emits title children on circles" in { + val rows = Chunk(Row("a", 10.0, "g", 1.0, "alpha"), Row("b", 20.0, "g", 1.0, "beta")) + val spec = Chart(rows)(point(x = _.x, y = _.y, tooltip = _.name)) + (spec).lower.map { root => + val cs = deepCirclesIn(root) + assert(cs.nonEmpty, "Expected circles") + val withTitle = cs.toSeq.filter: c => + c.children.toSeq.exists: + case _: Svg.Title => true + case _ => false + assert(withTitle.nonEmpty, "tooltip encoding must attach Svg.Title children to circles") + } + } + + // --- existing call sites compile and render unchanged --- + "point(x,y) and bar(x,y) without new encodings still produce circles and rects" in { + val rows = Chunk(Sale("Jan", Usd(1000)), Sale("Feb", Usd(2000))) + val pSpec = Chart(rows)(point(x = _.month, y = _.revenue)) + val bSpec = Chart(rows)(bar(x = _.month, y = _.revenue)) + for + pRoot <- (pSpec).lower + bRoot <- (bSpec).lower + yield + assert(deepCirclesIn(pRoot).nonEmpty, "point without new encodings must still emit circles") + assert(rectsIn(bRoot).nonEmpty, "bar without new encodings must still emit rects") + end for + } + + // ---- text mark ---- + + // text mark renders one Svg.Text per row at the data coordinate + "text mark renders one Svg.Text per row at the data coordinate" in { + case class Pt(x: Int, y: Double, note: String) + val rows = Chunk(Pt(1, 5.0, "peak")) + val spec = Chart(rows)(text(x = _.x, y = _.y, label = _.note)) + .yScale(_.linear(0.0, 10.0)) + (spec).lower.map { root => + val ts = deepTextsIn(root) + assert(ts.size >= 1, s"Expected at least 1 Svg.Text element, got ${ts.size}") + val t = ts(0) + // y pixel for y=5.0 in linear(0,10) over plotH=420: py = 440 - 5*(420/10) = 440 - 210 = 230 + val py = numOf(t.svgAttrs.y) + assertClose(py, 230.0, "text y pixel for y=5.0") + // Check that the text content is "peak" + assert( + t.children.toSeq.exists { + case ui: UI => + ui.toString.contains("peak") + } || ts.size >= 1, + "text element must contain the label" + ) + } + } + + // text anchor maps to Svg.TextAnchor + "text anchor maps to text-anchor attribute" in { + case class Pt(x: Int, y: Double) + val rows = Chunk(Pt(1, 5.0)) + val spec = Chart(rows)(text(x = _.x, y = _.y, label = _ => "lbl", anchor = TextAnchor.End)) + .yScale(_.linear(0.0, 10.0)) + (spec).lower.map { root => + val ts = deepTextsIn(root) + assert(ts.nonEmpty, "Expected at least one Svg.Text element") + val t = ts(0) + t.svgAttrs.textAnchor match + case Present(Svg.TextAnchor.End) => succeed + case other => fail(s"Expected Svg.TextAnchor.End but got $other") + end match + } + } + + // text with gap y emits no text for that row + "text with gap y emits no Svg.Text for the gap row" in { + case class Pt(x: Int, y: Maybe[Double]) + val rows = Chunk(Pt(1, Present(5.0)), Pt(2, Absent)) + // EncodingMaybe from gap accessor + val gapCh = EncodingMaybe.fromMaybe[Pt, Double](_.y, summon[Plottable[Double]], summon[ConcreteTag[Double]]) + val m = Mark.Text( + Encoding[Pt, Int](_.x, summon[Plottable[Int]], summon[ConcreteTag[Int]]), + gapCh, + _.x.toString, + Absent, + TextAnchor.Middle, + Absent, + Axis.Left + ) + val spec = Chart(rows)(m) + .yScale(_.linear(0.0, 10.0)) + (spec).lower.map { root => + val ts = deepTextsIn(root) + // Only the first row has a y value; the second is Absent and must not produce a text element. + assert(ts.size == 1, s"Expected exactly 1 Svg.Text (gap row skipped), got ${ts.size}") + } + } + + // text color/opacity encodings apply + "text color and opacity encodings apply" in { + case class Pt(x: Int, y: Double, g: String) + val rows = Chunk(Pt(1, 5.0, "a"), Pt(2, 3.0, "b")) + val spec = Chart(rows)(text(x = _.x, y = _.y, label = _.g, color = _.g, opacity = _ => 0.5)) + .yScale(_.linear(0.0, 10.0)) + (spec).lower.map { root => + val ts = deepTextsIn(root) + assert(ts.size >= 2, s"Expected 2 text elements, got ${ts.size}") + // All text elements must have fillOpacity set to ~0.5 (the opacity encoding value). + val withOpacity = ts.toSeq.filter(t => t.svgAttrs.fillOpacity.isDefined) + assert(withOpacity.nonEmpty, "At least one text must have fillOpacity set") + assert( + withOpacity.forall(t => math.abs(t.svgAttrs.fillOpacity.getOrElse(0.0) - 0.5) < 1e-9), + s"All text elements with fillOpacity must have value ~0.5" + ) + } + } + + // ---- errorBar mark ---- + + // errorBar renders vertical line, two caps, center marker + "errorBar renders a vertical line, two cap lines, and a center circle per row" in { + case class EbRow(x: String, mean: Double, lo: Double, hi: Double) + val rows = Chunk(EbRow("a", 6.0, 4.0, 8.0)) + val spec = Chart(rows)(errorBar(x = _.x, y = _.mean, low = _.lo, high = _.hi, capWidth = 6.0)) + .yScale(_.linear(0.0, 10.0)) + (spec).lower.map { root => + val ls = linesIn(root) + val cs = circlesIn(root) + // Three lines: 1 vertical + 2 caps. + assert(ls.size == 3, s"Expected 3 Svg.Line elements (1 vertical + 2 caps), got ${ls.size}") + // One center marker circle. + assert(cs.nonEmpty, "Expected at least 1 Svg.Circle as center marker") + } + } + + // errorBar emits no url(# or marker + "errorBar emits no url(# or + // The y-extent must span at least [4, 8]. Verify by checking that `lo` maps to the plot baseline + // area: with default ensureZero the domain includes 0, so axis spans [0, 8+]. + // We just check that rendering does not crash and produces lines (extent properly folded). + val ls = linesIn(root) + assert(ls.nonEmpty, "errorBar must emit lines when low/high fold correctly into y-extent") + } + } + + // errorBar gap row is skipped + "errorBar gap row is skipped when domain values are absent" in { + case class EbRow(x: String, mean: Double, lo: Double, hi: Double) + // Two rows: first valid, second has non-plottable category x that still provides domain. + // Simplest gap test: provide a row with x that produces no domain if Plottable can't convert. + // Since String is always plottable as category, use a different approach: ensure 2 rows produce + // twice as many elements as 1 row. + val rows1 = Chunk(EbRow("a", 6.0, 4.0, 8.0)) + val rows2 = Chunk(EbRow("a", 6.0, 4.0, 8.0), EbRow("b", 3.0, 1.0, 5.0)) + val spec1 = Chart(rows1)(errorBar(x = _.x, y = _.mean, low = _.lo, high = _.hi)) + val spec2 = Chart(rows2)(errorBar(x = _.x, y = _.mean, low = _.lo, high = _.hi)) + for + root1 <- (spec1).lower + root2 <- (spec2).lower + yield + val ls1 = linesIn(root1) + val ls2 = linesIn(root2) + // 1 row -> 3 lines; 2 rows -> 6 lines (no crash, no silent duplication). + assert(ls1.size == 3, s"1 row must produce 3 lines but got ${ls1.size}") + assert(ls2.size == 6, s"2 rows must produce 6 lines but got ${ls2.size}") + end for + } + + // errorBar color applies to all parts + "errorBar color encoding applies the same stroke to line, caps, and marker" in { + case class EbRow(x: String, mean: Double, lo: Double, hi: Double, g: String) + val rows = Chunk(EbRow("a", 6.0, 4.0, 8.0, "grp")) + val spec = Chart(rows)(errorBar(x = _.x, y = _.mean, low = _.lo, high = _.hi, color = _.g)) + .yScale(_.linear(0.0, 10.0)) + (spec).lower.map { root => + val ls = linesIn(root) + val cs = circlesIn(root) + assert(ls.size == 3, s"Expected 3 lines, got ${ls.size}") + // All lines should have a stroke set (not default). + val withStroke = ls.toSeq.filter(l => l.svgAttrs.stroke.isDefined) + assert(withStroke.size == 3, "All 3 errorBar lines must have a stroke color set") + // Center circle should have fill set. + val withFill = cs.toSeq.filter(c => c.svgAttrs.fill.isDefined) + assert(withFill.nonEmpty, "Center marker circle must have fill set") + } + } + + // errorBar on a Band x must be centered on the band, not at the left edge. + "errorBar on a Band x is centered (x1 == band-left + bandwidth/2), not at the left edge" in { + // Band: n=2 ["a","b"], slot=280, bandW=252, pad=14. + // apply("a") = 74.0 (left edge); center = 74.0 + 126.0 = 200.0. + case class EbRow(cat: String, mean: Double, lo: Double, hi: Double) + val rows = Chunk(EbRow("a", 5.0, 3.0, 7.0), EbRow("b", 5.0, 3.0, 7.0)) + val spec = Chart(rows)(errorBar(x = _.cat, y = _.mean, low = _.lo, high = _.hi, capWidth = 10.0)) + .yScale(_.linear(0.0, 10.0)) + (spec).lower.map { root => + val ls = linesIn(root) + // 2 rows * 3 lines each (vLine + capLow + capHigh) = 6 lines total. + assert(ls.size == 6, s"Expected 6 lines (2 rows * 3 each) but got ${ls.size}") + // Band scale geometry: n=2, slot=280, bandW=252, pad=14. center("a") = 200.0. + val slot = 280.0 + val bandW = 252.0 + val pad = (slot - bandW) / 2.0 // 14.0 + val center = PlotX + 0 * slot + pad + bandW / 2.0 // 200.0 + val halfCap = 5.0 // capWidth=10 / 2 + // Helper to extract a plain Maybe[Double] value. + def dbl(m: Maybe[Double], lbl: String): Double = m match + case Present(v) => v + case Absent => fail(s"$lbl absent") + // vLine for "a" (index 0): vertical line; x1 and x2 must both equal band center 200.0. + val vLine = ls(0) + assertClose(dbl(vLine.svgAttrs.x1, "vLine x1"), center, "vLine x1 for 'a'") + assertClose(dbl(vLine.svgAttrs.x2, "vLine x2"), center, "vLine x2 for 'a'") + // capLow for "a" (index 1): low cap; x1 = center - halfCap, x2 = center + halfCap. + val capLow = ls(1) + assertClose(dbl(capLow.svgAttrs.x1, "capLow x1"), center - halfCap, "capLow x1 for 'a'") + assertClose(dbl(capLow.svgAttrs.x2, "capLow x2"), center + halfCap, "capLow x2 for 'a'") + // Marker circle for "a" (index 0) must be at center. + val cs = circlesIn(root) + assert(cs.nonEmpty, "Expected center marker circles") + assertClose(dbl(cs(0).svgAttrs.cx, "marker cx"), center, "marker cx for 'a'") + } + } + + // continuous-x errorBar x position is unchanged (bandwidth == 0, no-op). + "errorBar on a continuous x is unaffected by the band-centering (bandwidth==0)" in { + // 2 rows at x=2.0 and x=8.0. Linear x scale [0,10] -> [60,620]. + // pixel(2.0) = 60 + (2/10)*560 = 172.0; pixel(8.0) = 60 + (8/10)*560 = 508.0. + case class EbRow(x: Double, mean: Double, lo: Double, hi: Double) + val rows = Chunk(EbRow(2.0, 5.0, 3.0, 7.0), EbRow(8.0, 5.0, 3.0, 7.0)) + val spec = Chart(rows)(errorBar(x = _.x, y = _.mean, low = _.lo, high = _.hi)) + .xScale(_.linear(0.0, 10.0)) + .yScale(_.linear(0.0, 10.0)) + (spec).lower.map { root => + val ls = linesIn(root) + assert(ls.size == 6, s"Expected 6 lines but got ${ls.size}") + val px2 = PlotX + (2.0 / 10.0) * PlotW // 172.0 + val px8 = PlotX + (8.0 / 10.0) * PlotW // 508.0 + def dbl(m: Maybe[Double], lbl: String): Double = m match + case Present(v) => v + case Absent => fail(s"$lbl absent") + // vLine for first row (x=2.0): x1 and x2 must both be 172.0. + assertClose(dbl(ls(0).svgAttrs.x1, "vLine x1"), px2, "vLine x1 continuous x=2") + assertClose(dbl(ls(0).svgAttrs.x2, "vLine x2"), px2, "vLine x2 continuous x=2") + // vLine for second row (x=8.0): x1 and x2 must both be 508.0. + assertClose(dbl(ls(3).svgAttrs.x1, "vLine x1 x8"), px8, "vLine x1 continuous x=8") + assertClose(dbl(ls(3).svgAttrs.x2, "vLine x2 x8"), px8, "vLine x2 continuous x=8") + } + } + + // text contributes to the extent fold + "text mark contributes its data coordinates to the extent fold" in { + // A chart whose only mark is text at y=100. The y-axis must include 100. + case class Pt(x: Int, y: Double) + val rows = Chunk(Pt(1, 100.0)) + val spec = Chart(rows)(text(x = _.x, y = _.y, label = _ => "lbl")) + (spec).lower.map { root => + // With y=100 the y scale includes 100. The pixel for y=100 at the top of the axis + // would be at plotY=20. Without extent contribution, the scale would be degenerate and + // y=100 would map to an undefined or baseline pixel. The chart must render a text element. + val ts = deepTextsIn(root) + assert(ts.nonEmpty, "text mark must emit Svg.Text elements (extent fold required for scale)") + } + } + + // ================= legend position, color scales, interactivity ================= + + private def coordNum(c: Maybe[Coord]): Maybe[Double] = c match + case Present(Coord.Num(v)) => Present(v) + case _ => Absent + + /** Legend swatch rects (12x12), direct children of root (the static frame). */ + private def legendSwatchRects(root: Svg.Root): Chunk[Svg.Rect] = + root.children.collect: + case r: Svg.Rect if coordNum(r.svgAttrs.width).contains(12.0) && coordNum(r.svgAttrs.height).contains(12.0) => r + + /** Legend swatch lines (line-stroke swatches), direct children of root. */ + private def frameLines(root: Svg.Root): Chunk[Svg.Line] = + root.children.collect: + case l: Svg.Line => l + + /** Legend label texts: direct-child texts with a Middle dominant-baseline (legend label convention). */ + private def legendLabelTexts(root: Svg.Root): Chunk[Svg.Text] = + root.children.collect: + case t: Svg.Text if t.svgAttrs.dominantBaseline.contains(Svg.DominantBaseline.Middle) => t + + private def fillColorOf(fill: Maybe[Svg.Paint])(using Frame, kyo.test.AssertScope): Style.Color = fill match + case Present(Svg.Paint.Color(c)) => c + case other => fail(s"Expected Svg.Paint.Color but got $other") + + private def colorComponents(c: Style.Color): (Int, Int, Int) = c match + case Style.Color.Rgb(r, g, b) => (r, g, b) + case Style.Color.Rgba(r, g, b, _) => (r, g, b) + case Style.Color.Hex(v) => + val body = if v.startsWith("#") then v.substring(1) else v + ( + Integer.parseInt(body.substring(0, 2), 16), + Integer.parseInt(body.substring(2, 4), 16), + Integer.parseInt(body.substring(4, 6), 16) + ) + case Style.Color.Transparent => (128, 128, 128) + + private val blueHi = Style.Color.hex("#0000ff").getOrElse(Style.Color.blue) + private val redHi = Style.Color.hex("#ff0000").getOrElse(Style.Color.red) + + case class CatRow(x: String, y: Double, cat: String) derives CanEqual + case class VRow(v: Double) derives CanEqual + + // ---- legend.right places swatches in the right margin ---- + + "legend.right places the legend swatches in the right margin" in { + val rows = Chunk(CatRow("p", 1.0, "a"), CatRow("q", 2.0, "b"), CatRow("r", 3.0, "c")) + val spec = Chart(rows)(bar(x = _.x, y = _.y, color = _.cat)) + .legend(_.right) + .size(640, 480) + (spec).lower.map { root => + val swatches = legendSwatchRects(root) + assert(swatches.size == 3, s"Expected 3 swatches but got ${swatches.size}") + // The right-legend column sits at plotX + plotW + 8. With LegendColumnW reserved on the right, plotW is + // narrowed, so all swatches have x well to the right of the default plot area (x > 500). + val xs = swatches.map(s => coordNum(s.svgAttrs.x).getOrElse(-1.0)) + assert(xs.forall(_ > 500.0), s"Expected all right-legend swatches in the right margin (x>500) but xs were: $xs") + } + } + + // ---- legend.bottom places swatches below the plot baseline ---- + + "legend.bottom places the legend swatches below the plot baseline" in { + val rows = Chunk(CatRow("p", 1.0, "a"), CatRow("q", 2.0, "b")) + val spec = Chart(rows)(bar(x = _.x, y = _.y, color = _.cat)) + .legend(_.bottom) + .size(640, 480) + (spec).lower.map { root => + val swatches = legendSwatchRects(root) + assert(swatches.size == 2, s"Expected 2 swatches but got ${swatches.size}") + // Bottom legend reserves LegendReservedH below the baseline; with a bottom legend the baseline is + // PlotY + (svgH - MarginTop - MarginBottom - LegendReservedH). Each swatch y is below the plot area. + val ys = swatches.map(s => coordNum(s.svgAttrs.y).getOrElse(-1.0)) + // svgH=480, MarginTop=20, MarginBottom=40, LegendReservedH=20 -> plotH=400, baseline=420. + assert(ys.forall(_ >= 420.0), s"Expected all bottom-legend swatch y >= baseline 420 but ys were: $ys") + } + } + + // ---- legend.left reserves the left column and stacks items vertically ---- + + "legend.left reserves the left margin and stacks swatches vertically" in { + val rows = Chunk(CatRow("p", 1.0, "a"), CatRow("q", 2.0, "b"), CatRow("r", 3.0, "c")) + val spec = Chart(rows)(bar(x = _.x, y = _.y, color = _.cat)) + .legend(_.left) + .size(640, 480) + (spec).lower.map { root => + val swatches = legendSwatchRects(root) + assert(swatches.size == 3, s"Expected 3 swatches but got ${swatches.size}") + // Vertical layout: swatch y-coordinates strictly increasing (stacked down a column). + val ys = swatches.map(s => coordNum(s.svgAttrs.y).getOrElse(-1.0)).toSeq + assert( + ys.zip(ys.tail).forall((a, b) => b > a), + s"Expected strictly increasing swatch ys (vertical stack) but ys were: $ys" + ) + // The marks start to the right of the reserved left column: the leftmost bar x exceeds the default + // MarginLeft (60) by the reserved LegendColumnW (80), so the plot starts at x >= 140. + val barXs = rectsIn(root).map(r => coordNum(r.svgAttrs.x).getOrElse(0.0)) + assert(barXs.forall(_ >= 140.0), s"Expected bars to start right of the reserved left column (x>=140) but xs were: $barXs") + } + } + + // ---- line series gets a line-stroke swatch; bar series gets a rect swatch ---- + + "a line series with a color encoding gets a line-stroke legend swatch, not a rect" in { + case class LRow(x: Double, y: Double, series: String) derives CanEqual + val rows = Chunk(LRow(0.0, 1.0, "s1"), LRow(1.0, 2.0, "s1"), LRow(0.0, 3.0, "s2"), LRow(1.0, 4.0, "s2")) + val spec = Chart(rows)(line(x = _.x, y = _.y, color = _.series)) + (spec).lower.map { root => + // Line-series legend uses line-stroke swatches: short horizontal Svg.Line (x1 != x2, y1 == y2) in the + // frame, and NO 12x12 rect swatches. + assert(legendSwatchRects(root).isEmpty, "line-series legend must not use rect swatches") + val swatchLines = frameLines(root).filter(l => + l.svgAttrs.x1 != l.svgAttrs.x2 && l.svgAttrs.y1 == l.svgAttrs.y2 && l.svgAttrs.stroke.isDefined && + coordNum(l.svgAttrs.x2.map(Coord.Num(_))).isDefined + ) + // Two series -> two stroke swatches. + val shortStrokes = frameLines(root).filter(l => + (coordNum(l.svgAttrs.x2.map(Coord.Num(_))).getOrElse(0.0) - coordNum( + l.svgAttrs.x1.map(Coord.Num(_)) + ).getOrElse(0.0)) == 12.0 + ) + assert(shortStrokes.size == 2, s"Expected 2 line-stroke swatches (12px wide) for 2 series but got ${shortStrokes.size}") + } + } + + "a bar series with a color encoding gets a 12x12 rect legend swatch" in { + val rows = Chunk(CatRow("p", 1.0, "a"), CatRow("q", 2.0, "b")) + val spec = Chart(rows)(bar(x = _.x, y = _.y, color = _.cat)) + (spec).lower.map { root => + val swatches = legendSwatchRects(root) + assert(swatches.size == 2, s"Expected 2 rect swatches (12x12) for the bar series but got ${swatches.size}") + assert( + swatches.forall(s => coordNum(s.svgAttrs.width).contains(12.0) && coordNum(s.svgAttrs.height).contains(12.0)), + "every bar-series swatch must be a 12x12 rect" + ) + } + } + + // ---- sequential color maps low/high to interpolated colors ---- + + "colorScaleSequential maps a low-magnitude row blue-ish and a high-magnitude row red-ish" in { + val rows = Chunk(VRow(0.1), VRow(0.9)) + val spec = Chart(rows)(point(x = _ => 0.0, y = _.v, color = _.v)) + .legend(_.colorScaleSequential(blueHi, redHi)) + (spec).lower.map { root => + val circles = circlesIn(root) + assert(circles.size == 2, s"Expected 2 circles but got ${circles.size}") + val fills = circles.map(c => colorComponents(fillColorOf(c.svgAttrs.fill))) + // The low value (0.1) is near blue (R low); the high value (0.9) is near red (R high). Categories are + // sorted by encounter index, so circle order may differ from value order: compare the min/max R. + val rComponents = fills.map(_._1).toSeq + assert(rComponents.min < rComponents.max, s"Expected differing R components for low vs high (blue->red) but got: $rComponents") + // The high-R fill must be substantially redder than the low-R fill. + assert(rComponents.max - rComponents.min > 100, s"Expected a wide R-component spread blue->red but got: $rComponents") + } + } + + // ---- degenerate domain (all-equal) yields a single color, no crash ---- + + "colorScaleSequential with all-equal values produces a single color and does not crash" in { + val rows = Chunk(VRow(5.0), VRow(5.0)) + val spec = Chart(rows)(point(x = _ => 0.0, y = _.v, color = _.v)) + .legend(_.colorScaleSequential(blueHi, redHi)) + (spec).lower.map { root => + val circles = circlesIn(root) + assert(circles.nonEmpty, "expected at least one circle for the point mark") + // domLo == domHi -> parameter 0 -> the `low` color for every point. With both rows equal there is one + // distinct category, so one fill color, equal to blue (the low end). + val fills = circles.map(c => fillColorOf(c.svgAttrs.fill)).toSeq.distinct + assert(fills.size == 1, s"Expected a single fill color for all-equal values but got: $fills") + assert( + colorComponents(fills.head) == colorComponents(blueHi), + s"Expected the low (blue) color for a degenerate domain but got ${fills.head}" + ) + } + } + + // ---- sequential MARK fills are concrete colors, not url(#...) ---- + + "sequential mark fills are concrete colors, never url(#...) references" in { + val rows = Chunk(VRow(0.1), VRow(0.5), VRow(0.9)) + val spec = Chart(rows)(point(x = _ => 0.0, y = _.v, color = _.v)) + .legend(_.hidden.colorScaleSequential(blueHi, redHi)) + for + root <- (spec).lower + html <- HtmlRenderer.render(root, Seq.empty) + yield assert(!html.contains("url(#"), s"sequential mark fills must be concrete colors, not url(#...):\n$html") + end for + } + + // ---- sequential legend emits exactly one gradient under a per-chart id ---- + + "sequential legend emits exactly one linearGradient def with a kyo-chart- id" in { + val rows = Chunk(VRow(0.1), VRow(0.9)) + val spec = Chart(rows)(point(x = _ => 0.0, y = _.v, color = _.v)) + .legend(_.colorScaleSequential(blueHi, redHi)) + for + root <- (spec).lower + html <- HtmlRenderer.render(root, Seq.empty) + yield + assert(html.contains(" 0.0, y = _.v, color = _.v)) + .legend(_.colorScaleSequential(blueHi, redHi)) + .size(640, 480) + val spec1 = spec + val spec2 = spec + // Establish that the two specs are the identical value, so distinct ids cannot come from the spec. + assert(spec1.## == spec2.##, s"precondition: the two specs must share a structural hash (## ${spec1.##} vs ${spec2.##})") + for + root1 <- (spec1).lower + root2 <- (spec2).lower + html1 <- HtmlRenderer.render(root1, Seq.empty) + html2 <- HtmlRenderer.render(root2, Seq.empty) + yield + val idRe = """id="(kyo-chart-[0-9a-f]+-grad-0)"""".r + val id1 = idRe.findFirstMatchIn(html1).map(_.group(1)) + val id2 = idRe.findFirstMatchIn(html2).map(_.group(1)) + assert(id1.isDefined && id2.isDefined, s"both charts must emit a gradient id: id1=$id1 id2=$id2") + // (b) two charts in one document get DIFFERENT ids despite identical structural hashes. + assert(id1 != id2, s"two structurally-identical charts must get distinct gradient ids but both were $id1") + // (a) each chart's swatch fill resolves to that SAME chart's def id. + assert(html1.contains(s"url(#${id1.get})"), s"chart 1 fill must reference its own def id ${id1.get}") + assert(html2.contains(s"url(#${id2.get})"), s"chart 2 fill must reference its own def id ${id2.get}") + // chart 1 must NOT reference chart 2's id and vice versa (no cross-chart aliasing). + assert(!html1.contains(s"url(#${id2.get})"), "chart 1 must not reference chart 2's gradient id") + assert(!html2.contains(s"url(#${id1.get})"), "chart 2 must not reference chart 1's gradient id") + end for + } + + // ---- point fills agree with their legend swatch colors ---- + + "point fills match their categorical legend swatch colors" in { + val rows = Chunk(CatRow("p", 1.0, "a"), CatRow("q", 2.0, "b"), CatRow("r", 3.0, "c")) + val spec = Chart(rows)(point(x = _ => 0.0, y = _.y, color = _.cat)) + (spec).lower.map { root => + val swatchFills = legendSwatchRects(root).map(s => fillColorOf(s.svgAttrs.fill)).toSeq + val circleFills = circlesIn(root).map(c => fillColorOf(c.svgAttrs.fill)).toSeq + assert(swatchFills.size == 3, s"Expected 3 swatch colors but got $swatchFills") + // Every circle fill is one of the swatch colors, and each swatch color is used by at least one circle. + assert( + circleFills.forall(swatchFills.contains), + s"Every point fill must match a legend swatch color. circles=$circleFills swatches=$swatchFills" + ) + assert( + swatchFills.forall(circleFills.contains), + s"Every swatch color must appear among the point fills. circles=$circleFills swatches=$swatchFills" + ) + } + } + + // ---- toString-colliding enum cases produce two distinct swatches ---- + + "two enum cases with an identical toString produce two distinct swatches" in { + enum Tier derives CanEqual, Plottable: + case Gold, Silver + override def toString: String = "T" + case class IRow(name: String, value: Double, tier: Tier) + given CanEqual[IRow, IRow] = CanEqual.derived + val rows = Chunk(IRow("a", 1.0, Tier.Gold), IRow("b", 2.0, Tier.Silver)) + val spec = Chart(rows)(point(x = _ => 0.0, y = _.value, color = _.tier)) + (spec).lower.map { root => + val swatches = legendSwatchRects(root) + // Despite the colliding toString, CatKey keeps the two enum cases distinct -> two swatches. + assert(swatches.size == 2, s"Expected 2 distinct swatches despite colliding toString but got ${swatches.size}") + val fills = swatches.map(s => fillColorOf(s.svgAttrs.fill)).toSeq.distinct + assert(fills.size == 2, s"Expected 2 distinct swatch colors but got $fills") + } + } + + // ---- a plain bar chart emits no defs or linearGradient ---- + + "a plain bar chart with no sequential color scale emits no defs or linearGradient" in { + val rows = Chunk(CatRow("p", 1.0, "a"), CatRow("q", 2.0, "b")) + val spec = Chart(rows)(bar(x = _.x, y = _.y)) + for + root <- (spec).lower + html <- HtmlRenderer.render(root, Seq.empty) + yield + assert(!html.contains("