Zum Inhalt

JDL Specification — Appendix SP: Span, Interpolation & der ..-Operator

Status: Draft
Abhängigkeiten: Section 2 (Type System), Section 8 (Language Reference), Appendix TF (Type Functions)
Namespace: jdl::span (Core — eingebettet)


SP.1 Motivation

Der ..-Operator ist in JDL kein syntaktischer Zucker für einen festen Zahlenbereich. Er ist ein protokollgebundener Operator der auf jedem Typ operiert der Spannable implementiert. Das Ergebnis ist immer ein Span[T] — eine lazy beschriebene Transformation zwischen zwei Werten, die erst durch at, gen oder stream materialisiert wird.

1..10, Color.red..Color.green, Instant(0ms)..Instant(1000ms) und Vec2(0,0)..Vec2(1,1) sind exakt dasselbe Sprachkonstrukt mit unterschiedlichen Typen — kein Sonderfall, keine eingebaute Magie.


SP.2 Protokoll-Hierarchie

Interpolatable[T]      — definiert wie zwischen zwei T-Werten interpoliert wird
    └── Spannable[T]   — bindet ".." an Interpolatable, liefert Span[T]
            └── Span[T]
                    ├── + Steppable[T]  →  Range[T]      (diskrete Schritte)
                    ├── + Keyframes     →  Sequence[T]   (mehrere Stützpunkte)
                    └── + precompute    →  SpanLut[T]    (O(1) Lookup-Table)

SP.3 Interpolatable[T]

Das Basis-Protokoll. Beschreibt wie zwischen self und other zum Zeitpunkt t ∈ [0.0, 1.0] interpoliert wird.

protocol Interpolatable[T] {
    def interpolate(self, other: T, t: f64) -> T
    //              t=0.0 → self              t=1.0 → other
}

SP.3.1 Invarianten

Semantische Kontrakte — der Compiler prüft diese nicht:

  • x.interpolate(y, 0.0) == x
  • x.interpolate(y, 1.0) == y
  • t außerhalb [0.0, 1.0] ist erlaubt — Extrapolation ist gültig

SP.3.2 Eingebaute Implementierungen

provide Interpolatable[f32] for f32 {
    def interpolate(self, other: f32, t: f64) -> f32 =
        self + (other - self) * t.f32()
}

provide Interpolatable[f64] for f64 {
    def interpolate(self, other: f64, t: f64) -> f64 =
        self + (other - self) * t
}

provide Interpolatable[i32] for i32 {
    def interpolate(self, other: i32, t: f64) -> i32 =
        (self.f64() + (other - self).f64() * t).round().i32()
}

// Tuple — komponentenweise; A und B werden hier neu eingeführt:
provide[A, B] Interpolatable[(A, B)] for (A, B)
where A: Interpolatable[A]
    , B: Interpolatable[B]
{
    def interpolate(self, other: (A, B), t: f64) -> (A, B) =
        ( self.0.interpolate(other.0, t)
        , self.1.interpolate(other.1, t)
        )
}

provide<(A, B)> Interpolatable for (A, B)
where A: Interpolatable[A]
    , B: Interpolatable[B]
{
    def interpolate(self, other: (A, B), t: f64) -> (A, B) =
        ( self.0.interpolate(other.0, t)
        , self.1.interpolate(other.1, t)
        )
}

SP.3.3 Nutzer-definierte Implementierungen

type Color: struct { r: f32, g: f32, b: f32, a: f32 }

provide Interpolatable[Color] for Color {
    def interpolate(self, other: Color, t: f64) -> Color = Color {
        r: self.r.interpolate(other.r, t)
        g: self.g.interpolate(other.g, t)
        b: self.b.interpolate(other.b, t)
        a: self.a.interpolate(other.a, t)
    }
}

type Instant: struct { ms: i64 }

provide Interpolatable[Instant] for Instant {
    def interpolate(self, other: Instant, t: f64) -> Instant =
        Instant { ms: self.ms.interpolate(other.ms, t) }
}

type Vec2: struct { x: f64, y: f64 }

provide Interpolatable[Vec2] for Vec2 {
    def interpolate(self, other: Vec2, t: f64) -> Vec2 = Vec2 {
        x: self.x.interpolate(other.x, t)
        y: self.y.interpolate(other.y, t)
    }
}

SP.4 Spannable[T] — Der ..-Operator

Spannable bindet den ..-Operator an Interpolatable. Jeder Typ der Interpolatable[T] implementiert bekommt Spannable[T] automatisch — kein manuelles provide nötig.

protocol Spannable[T] {
    def span(self, other: T) -> Span[T]
}
:> <{ operator: ".." }>

// T wird hier neu eingeführt — [T] nach provide ist nötig:
provide[T] Spannable[T] for T where T: Interpolatable[T]
    , T: Copyable
{
    def span(self, other: T) -> Span[T] =
        Span { from: self, to: other }
}

Hinweis (safe-by-default): Die Stdlib liefert Spannable automatisch nur für Copyable-Typen. Nicht-kopierbare / ressourcenartige Typen müssen explizit einen Wrapper/Newtype wählen oder bewusst eine eigene Spannable-Implementierung bereitstellen.

Desugaring:

a..b
  → a.span(b)
  → Span { from: a, to: b }

SP.5 Span[T]

type Span[T]: struct {
    from: T
    to:   T
} where T: Interpolatable[T]

Span[T] ist pure — gleicher t-Wert ergibt immer dieselbe Ausgabe. Daraus folgen bounded und cacheable automatisch durch die Refinement-Algebra.

SP.5.1 Kern-Operationen

// T kommt aus der Typdefinition — kein [T] nach provide nötig:
provide Span[T] where T: Interpolatable[T] {

    def at(self, t: f64) -> T =
        self.from.interpolate(self.to, t)

    def midpoint(self) -> T =
        self.at(0.5)

    def reversed(self) -> Span[T] =
        Span { from: self.to, to: self.from }

    def contains(self, value: T) -> bool
        where T: Comparable[T] =
        value >= self.from and value <= self.to

    def union(self, other: Span[T]) -> Span[T]
        where T: Comparable[T] =
        Span {
            from: min(self.from, other.from)
            to:   max(self.to,   other.to)
        }

    def intersection(self, other: Span[T]) -> Option[Span[T]]
        where T: Comparable[T]
    {
        val lo = max(self.from, other.from)
        val hi = min(self.to,   other.to)
        if lo <= hi then Some(Span { from: lo, to: hi })
        else None
    }
}

SP.5.2 Easing

Easing-Funktionen sind f64 -> f64 — normale Werte, mit >> komponierbar.

// Eingebaut in jdl::span:
val linear:    f64 -> f64 = t => t
val easeIn:    f64 -> f64 = t => t * t
val easeOut:   f64 -> f64 = t => t * (2.0 - t)
val easeInOut: f64 -> f64 = t => if t < 0.5
                                 then 2.0 * t * t
                                 else -1.0 + (4.0 - 2.0 * t) * t
val sine:      f64 -> f64 = t => Math.sin(t * Math.PI / 2.0)
val bounce:    f64 -> f64 = t => ...
val elastic:   f64 -> f64 = t => ...

// Komposition mit >>:
val easeInOutSine: f64 -> f64 = easeIn >> sine

SP.6 Diskrete Auswertung — gen

gen materialisiert einen Span[T] in einen endlichen Generator[T].

Normativ: samples ist die Anzahl der Ausgaben. Für samples >= 2 sind beide Endpunkte garantiert enthalten. Für samples == 1 wird nur from ausgegeben.

provide Span[T] where T: Interpolatable[T] {

    def gen(
        self
        samples: i32
        easing:  f64 -> f64 = linear
    ) -> Generator[T] {
        val n = samples.max(1)
        PullGen.unfold(
            state = 0,
            step  = i => {
                if i >= n then None
                else {
                    val t = if n == 1
                            then 0.0
                            else i.f64() / (n - 1).f64()
                    Some(( self.at(easing(t)), i + 1 ))
                }
            }
        )
    }

    def genStep(
        self
        step:   f64
        easing: f64 -> f64 = linear
    ) -> Generator[T] {
        val segments = (1.0 / step).ceil().i32().max(1)
        self.gen(samples = segments + 1, easing = easing)
    }
}

Verwendung:

val colors   = (Color.red..Color.green).gen(samples = 11) |> []   // inkl. Endpunkte
val animated = (0.0..1.0).gen(samples = 60, easing = easeInOut)

// ~60fps über 1 Sekunde:
val frames = (Instant(0ms)..Instant(1000ms)).genStep(step = 16.67 / 1000.0)

SP.7 Kontinuierliche Auswertung — stream

stream erzeugt einen unbegrenzten Stream[T] der extern getaktet wird.

provide Span[T] where T: Interpolatable[T] {

    // Extern getaktet — t kommt vom Konsumenten:
    def stream(
        self
        easing: f64 -> f64 = linear
    ) -> Stream[T] =
        Stream.fromPull(t => self.at(easing(t.clamp(0.0, 1.0))))

    // Zeitbasiert ist reine Stdlib-Komposition:
    // - `timer(duration)` erzeugt t ∈ [0.0, 1.0] (inkl. 1.0) und endet.
    // - `Span` bleibt pure; Effekte stecken im Driver.
    // schematisch: `streamAt` benötigt eine Clock-Dependency; normativ als expliziter Parameter oder über `Effect[R, E, D]` auszudrücken.
    def streamAt(
        self
        duration: Duration
        easing:   f64 -> f64 = linear
    ) -> Stream[T] =
        self.driveWith(timer(duration), easing)
}

Verwendung:

val anim = (Color.red..Color.green).streamAt(duration = 500ms, easing = sine)

anim |> onTick(16ms) |> render()
anim |> cycle() |> onTick(16ms) |> render()

// Zwei Spans synchronisiert:
(Color.red..Color.green).streamAt(500ms, easing = sine)
    |> zipWith((1.0..2.0).streamAt(500ms, easing = bounce))
    |> map((color, scale) => Frame { color, scale })
    |> onTick(16ms)
    |> render()

SP.8 Reaktive Steuerung — driveWith

Ein Span wird durch einen externen Stream[f64] gesteuert — der Stream liefert t, der Span berechnet den Wert.

provide Span[T] where T: Interpolatable[T] {

    def driveWith(
        self
        driver: Stream[f64]
        easing: f64 -> f64 = linear
    ) -> Stream[T] =
        driver |> map(t => self.at(easing(t.clamp(0.0, 1.0))))
}

Verwendung:

val scrollT: Stream[f64] = scrollY |> map(y => y / pageHeight)

val headerColor = (Color.transparent..Color.white).driveWith(scrollT)
val fontSize    = (16.0..12.0).driveWith(scrollT, easing = easeIn)
val opacity     = (1.0..0.0).driveWith(scrollT, easing = easeOut)

SP.9 Caching

Span[T] ist pure — gleicher t-Wert, gleiche Ausgabe. Jede Auswertungsform ist daher cacheable.

provide Span[T] where T: Interpolatable[T] {

    def precompute(
        self
        samples: i32
        easing:  f64 -> f64 = linear
    ) -> [T] =
        self.gen(samples = samples, easing = easing) |> []

    def lut(
        self
        resolution: i32 = 256
        easing:     f64 -> f64 = linear
    ) -> SpanLut[T] =
        SpanLut {
            table:      self.precompute(samples = resolution.max(1), easing = easing)
            resolution: resolution.max(1)
        }
}

type SpanLut[T]: struct {
    table:      [T]
    resolution: i32
} where T: Interpolatable[T]

provide SpanLut[T] where T: Interpolatable[T] {

    def at(self, t: f64) -> T {
        if self.resolution <= 1 then self.table[0]
        else {
            val idx = (t.clamp(0.0, 1.0) * (self.resolution - 1).f64()).round().i32()
            self.table[idx]
        }
    }

    def stream(self) -> Stream[T] =
        Stream.fromPull(t => self.at(t))
}

SP.9.1 Wann was verwenden

Situation Empfehlung
Einfache lineare Interpolation, einmalig gen direkt
Teure Interpolation, mehrfach abgerufen lut — einmal precomputen
Vollständig statische Animation precompute[T]
Reaktiver Stream, jeder t-Wert einmalig driveWith — kein Cache nötig

SP.10 Sequence[T] — Mehrere Keyframes

Für Animationen mit mehr als zwei Stützpunkten:

type Keyframe[T]: struct {
    t:     f64   // Position in [0.0, 1.0]
    value: T
}

type Sequence[T]: struct {
    keyframes: NonEmpty[[Keyframe[T]]]
} where T: Interpolatable[T]

provide Sequence[T] where T: Interpolatable[T] {

    def fromKeyframes(frames: NonEmpty[[Keyframe[T]]]) -> Self =
        Sequence { keyframes: frames.sortBy(f => f.t) }

    def fromValues(values: NonEmpty[[T]]) -> Self {
        val n     = values.len() - 1
        val step  = 1.0 / n.f64()
        val frames = values
            |> enumerate()
            |> map((i, v) => Keyframe { t: i.f64() * step, value: v })
            |> []
        Sequence.fromKeyframes(NonEmpty(frames))
    }

    def at(self, t: f64, easing: f64 -> f64 = linear) -> T {
        val clamped      = t.clamp(0.0, 1.0)
        val (prev, next) = self.surroundingFrames(clamped)
        val localT       = easing((clamped - prev.t) / (next.t - prev.t))
        prev.value.interpolate(next.value, localT)
    }

    def gen(
        self
        samples: i32
        easing:  f64 -> f64 = linear
    ) -> Generator[T] {
        val n = samples.max(1)
        PullGen.unfold(
            state = 0,
            step  = i => {
                if i >= n then None
                else {
                    val t = if n == 1 then 0.0 else i.f64() / (n - 1).f64()
                    Some(( self.at(t, easing), i + 1 ))
                }
            }
        )
    }

    // schematisch: `streamAt` benötigt eine Clock-Dependency; normativ als expliziter Parameter oder über `Effect[R, E, D]` auszudrücken.
    def streamAt(
        self
        duration: Duration
        easing:   f64 -> f64 = linear
    ) -> Stream[T] =
        self.driveWith(timer(duration), easing)

    def lut(
        self
        resolution: i32 = 256
        easing:     f64 -> f64 = linear
    ) -> SpanLut[T] =
        SpanLut {
            table:      self.gen(samples = resolution.max(1), easing = easing) |> []
            resolution: resolution.max(1)
        }

    def surroundingFrames(self, t: f64) -> (Keyframe[T], Keyframe[T]) = ...
}

Verwendung:

val gradient = Sequence.fromValues(NonEmpty([
    Color.red
    Color.yellow
    Color.green
    Color.blue
]))

gradient.at(0.33)                       // zwischen gelb und grün
gradient.gen(samples = 100) |> []        // 100 Werte als Array
gradient.streamAt(duration = 2000ms)    // 2-Sekunden-Animation
gradient.lut(resolution = 512).at(0.5) // O(1) nach precompute

SP.11 Range[T] — Diskreter Spezialfall

Range[T] ist Span[T] für Typen die Steppable implementieren — also Integer und ähnliche diskrete Typen. gen erzeugt exakte Werte statt interpolierte.

protocol Steppable[T]: Interpolatable[T] {
    def next(self)               -> T
    def prev(self)               -> T
    def distance(self, other: T) -> i64
}

provide Steppable[i32] for i32 {
    def next(self)                 -> i32 = self + 1
    def prev(self)                 -> i32 = self - 1
    def distance(self, other: i32) -> i64 = (other - self).i64().abs()
}

typefn Range[T] where T: Steppable[T] =
    Span[T] :> <{ discrete: true }>

provide Range[T] where T: Steppable[T] {

    // Überschreibt gen — exakte Schritte, keine Interpolation:
    def gen(self) -> Generator[T] {
        var current = self.from
        PullGen.fromPull(() => {
            if current > self.to then None
            else {
                val out = current
                current = current.next()
                Some(out)
            }
        })
    }

    def len(self) -> i64 =
        self.from.distance(self.to) + 1

    def slice[E](self, arr: [E]) -> [E] =
        arr[self.from..self.to]
}

Verwendung:

val r = 1..10
r.len()               // → 10
r.gen() |> []         // → [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

val arr = [10, 20, 30, 40, 50]
arr[1..3]             // → [20, 30, 40]
arr[..2]              // → [10, 20, 30]
arr[2..]              // → [30, 40, 50]

for i in 0..100 {
    Io.print(i)
}

SP.12 Refinement-Algebra

Span[T] trägt von sich aus:

pure:      true   — gleicher t-Wert → gleiche Ausgabe, keine Effekte
bounded:   true   — t ∈ [0.0, 1.0] ist der definierte Bereich
cacheable: true   — folgt aus pure

Wenn T zusätzliche Refinements trägt, propagieren diese:

// T ist Persistent → Span[T] kann persistiert werden:
type SavedGradient = Span[Color] :> Persistent

// T ist Concurrent → gen() kann parallel ausgewertet werden:
type ParallelField = Span[Vec2] :> Concurrent

// T ist Fallible → at() kann fehlschlagen:
type SafeSpan[T] = Span[T] :> Fallible
// → at(t) -> Result[T, InterpolationError]

SP.13 Anwendungsbeispiele

SP.13.1 Gradient mit LUT

val gradient = Sequence.fromValues(NonEmpty([
    Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }   // rot
    Color { r: 1.0, g: 0.5, b: 0.0, a: 1.0 }   // orange
    Color { r: 1.0, g: 1.0, b: 0.0, a: 1.0 }   // gelb
    Color { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }   // grün
]))

val lut = gradient.lut(resolution = 1024, easing = sine)

onTick(16ms) |> map(t => lut.at(t)) |> render()

SP.13.2 Scroll-getriebene Animation

val scrollT: Stream[f64] =
    scrollY |> map(y => (y / pageHeight).clamp(0.0, 1.0))

val bgColor  = (Color.white..Color.black).driveWith(scrollT)
val fontSize = (16.0..12.0).driveWith(scrollT, easing = easeIn)
val opacity  = (1.0..0.0).driveWith(scrollT, easing = easeOut)

zipWith3(bgColor, fontSize, opacity)
    |> map((bg, fs, op) => Style { background: bg, fontSize: fs, opacity: op })
    |> render()

SP.13.3 Physik-basiertes Easing

// Eine Funktion die eine Easing-Funktion zurückgibt:
def springEasing(stiffness: f64, damping: f64) -> (f64 -> f64) {
    val w = Math.sqrt(stiffness)
    val d = damping / (2.0 * Math.sqrt(stiffness))
    t => if d < 1.0 {
        val wd = w * Math.sqrt(1.0 - d * d)
        1.0 - Math.exp(-d * w * t)
            * (Math.cos(wd * t) + (d * w / wd) * Math.sin(wd * t))
    } else {
        1.0 - Math.exp(-w * t) * (1.0 + w * t)
    }
}

val spring   = springEasing(stiffness = 200.0, damping = 20.0)
val position = (Vec2(0.0, 0.0)..Vec2(400.0, 0.0))
    .streamAt(duration = 800ms, easing = spring)

SP.13.4 Audio — Sinuswelle als LUT

type Sample: struct { amplitude: f64 }

provide Interpolatable[Sample] for Sample {
    def interpolate(self, other: Sample, t: f64) -> Sample =
        Sample { amplitude: self.amplitude.interpolate(other.amplitude, t) }
}

val sineWave = Sequence.fromValues(NonEmpty(
    (0..360).gen()
        |> map(deg => Sample { amplitude: Math.sin(deg.f64() * Math.PI / 180.0) })
        |> []
))

val sineLut = sineWave.lut(resolution = 44100)   // 44.1 kHz

sineLut.stream() |> cycle() |> audioOut()

SP.14 Grammatik-Ergänzung (Section 8.12)

(* Ausdruck-Ebene: Span/Range ist immer binär *)
span_expr = expr ".." expr ;

(* Index/Slice-Ebene: Endpunkte optional *)
index_range = [expr] ".." [expr] ;

(* Pattern-Ebene: Rest via Spread, Wildcard via "_" *)
struct_pat = "{" field_pat { "," field_pat } [ "," "..." pat ] "}" ;
pat = "_" | ident | struct_pat | ... ;

Kontext-Trennung:

  • .. ist ein Ausdruck-/Index-Operator (a..b, arr[1..3], arr[..3], arr[1..]).
  • Pattern-Rest benutzt ... (z.B. { email, ..._ }). _ bleibt der normale Wildcard.

Disambiguierung im Lexer:

Token Kontext Bedeutung
.. Ausdruck / Index SpanOp — bindet an Spannable
... Pattern Rest/Spread (z.B. { a, ...rest }, { a, ..._ })
_ Pattern / Lambda Wildcard / Placeholder

SP.15 Zusammenfassung

Typ / Protokoll Zweck
Interpolatable[T] Kernoperation: interpolate(other, t)
Spannable[T] Bindet .. — Stdlib default für Interpolatable + Copyable
Span[T] Lazy Transformation zwischen zwei Werten
Steppable[T] Erweiterung für diskrete Typen
Range[T] Diskreter Span — exakte Schritte, Array-Slicing
Sequence[T] Mehrere Keyframes mit binärer Suche
SpanLut[T] Precomputed Lookup-Table — O(1) nach einmaligem precompute

.. ist überladbar, protokollgebunden, lazy, pure, cacheable, reaktiv und streambar — ohne ein einziges animationsspezifisches Feature im Core.