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) == xx.interpolate(y, 1.0) == ytauß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:
SP.5 Span[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.