(, SPy — Canvas Demo

SPy Demo

Tiny SPy/WASM demo.

Parameters

20
3 px/f
6 px

The SPy source code below is what drives the animation above. It is compiled to WebAssembly by the SPy compiler and runs directly in your browser — no Python runtime, no PyScript, no Pyodide.

demo_particles.spySPy
"""
SPy particle simulation using the canvas 2D API directly.

Known missing vs JS version:
- math.random not yet available: particles initialized deterministically

Note that closures are not yet supported in red function so we have to use few global
variables.

However, module level code is quite particular in SPy:

- It is evaluated at import time (i.e. during compilation).
- Global variables are by default constant and a keyword `var` is used to indicate that they
  are real variables.
- Moreover, currently `var` global variables can be only simple types and NULL pointers.

jsffi is also currently very limited. In particular, there is no equivalent to the
`new` JS keyword, so we need to rely on function helpers (like js_u8array_from_ptr
and js_new_ImageData).
"""

import math
import jsffi

from jsffi import JsRef
from unsafe import gc_alloc, gc_ptr
from time import time

WIDTH: i32 = 800
HEIGHT: i32 = 600
MAX_PARTICLES: i32 = 80
TWO_PI: f64 = 2.0 * math.pi


@struct
class Particle:
    x: f64
    y: f64
    vx: f64
    vy: f64
    hue: f64


@struct
class Params:
    n_particles: i32
    speed: f64
    radius: i32


var n_jsrefs: i32 = 0
var PARTICLES: gc_ptr[Particle] = gc_ptr[Particle].NULL
var PARAMS: gc_ptr[Params] = gc_ptr[Params].NULL
var JS_REFS: gc_ptr[JsRef] = gc_ptr[JsRef].NULL
CTX_IDX: i32 = 0


def get_ctx() -> JsRef:
    return JS_REFS[CTX_IDX]


def sync_slider[id]() -> None:
    document = jsffi.get_Document()
    span = document.getElementById(id + "Val")
    elem = document.getElementById(id)
    value = elem.value
    span.textContent = value
    setattr(PARAMS[0], id, value)
    # SPy has currently no destructor
    jsffi.drop_ref(span)
    jsffi.drop_ref(elem)
    jsffi.drop_ref(value)


def draw_particle(p: Particle, radius: i32, ctx: JsRef) -> None:
    r_f: f64 = float(radius)
    hue_i: i32 = int(p.hue) % 360
    hue_s = str(hue_i)
    glow_color = "hsla(" + hue_s + ", 100%, 60%, 0.6)"
    core_color = "hsl(" + hue_s + ", 100%, 75%)"
    transparent = "hsla(" + hue_s + ", 100%, 50%, 0)"

    grd = ctx.createRadialGradient(p.x, p.y, 0.0, p.x, p.y, r_f * 2.5)
    grd.addColorStop(0.0, glow_color)
    grd.addColorStop(1.0, transparent)
    ctx.fillStyle = grd
    ctx.beginPath()
    ctx.arc(p.x, p.y, r_f * 2.5, 0.0, TWO_PI)
    ctx.fill()

    ctx.fillStyle = core_color
    ctx.beginPath()
    ctx.arc(p.x, p.y, r_f, 0.0, TWO_PI)
    ctx.fill()
    jsffi.drop_ref(grd)


def simulate_step(n: i32, speed: f64, radius: i32) -> None:
    r_f: f64 = float(radius)
    w_f: f64 = float(WIDTH)
    h_f: f64 = float(HEIGHT)

    ctx = get_ctx()
    ctx.fillStyle = "rgba(15, 23, 42, 0.25)"
    ctx.fillRect(0.0, 0.0, w_f, h_f)

    for i in range(n):
        p: Particle = PARTICLES[i]

        mag: f64 = math.sqrt(p.vx * p.vx + p.vy * p.vy)
        if mag > 0.0001:
            vx = p.vx / mag * speed
            vy = p.vy / mag * speed
            p = Particle(p.x, p.y, vx, vy, p.hue)

        x = p.x + p.vx
        y = p.y + p.vy
        vx = p.vx
        vy = p.vy

        if x - r_f < 0.0:
            x = r_f
            vx = -p.vx
        if x + r_f > w_f:
            x = w_f - r_f
            vx = -vx
        if y - r_f < 0.0:
            y = r_f
            vy = -vy
        if y + r_f > h_f:
            y = h_f - r_f
            vy = -vy

        hue = (p.hue + 0.4) % 360.0
        p = Particle(x, y, vx, vy, hue)
        draw_particle(p, radius, ctx)
        PARTICLES[i] = p


def frame(timestamp: f64) -> None:
    console = jsffi.get_Console()

    params = PARAMS[0]

    t_start = time()
    simulate_step(params.n_particles, params.speed, params.radius)
    console.log("simulate_step done in", time() - t_start, "s")

    n_tmp = jsffi._debug_n_jsrefs()
    n_per_frame = n_tmp - n_jsrefs
    n_jsrefs = n_tmp

    console.log("n_per_frame:", n_per_frame, "; per part: ", n_per_frame / params.n_particles)

    jsffi.request_animation_frame(frame)
    # this is equivalent to
    # jsffi.drop_ref(jsffi.get_GlobalThis().requestAnimationFrame(frame))


def init_particles(speed: f64, radius: i32) -> None:
    """Deterministic sunburst init — replace with random angles once math.random exists."""
    cx: f64 = float(WIDTH) / 2.0
    cy: f64 = float(HEIGHT) / 2.0
    for i in range(MAX_PARTICLES):
        angle: f64 = float(i) * TWO_PI / MAX_PARTICLES
        spd: f64 = speed * (0.5 + float(i % 5) * 0.1)
        PARTICLES[i].x = cx
        PARTICLES[i].y = cy
        PARTICLES[i].vx = math.cos(angle) * spd
        PARTICLES[i].vy = math.sin(angle) * spd
        PARTICLES[i].hue = float(i * 360 // MAX_PARTICLES)


def main() -> None:
    jsffi.init()

    PARTICLES = gc_alloc[Particle](MAX_PARTICLES)
    PARAMS = gc_alloc[Params](1)

    JS_REFS = gc_alloc[JsRef](1)

    document = jsffi.get_Document()
    canvas = document.getElementById("canvas")
    ctx = canvas.getContext("2d")

    JS_REFS[CTX_IDX] = ctx

    window = jsffi.get_GlobalThis()
    document = jsffi.get_Document()

    PARAMS[0].n_particles = 20
    PARAMS[0].speed = 3.0
    PARAMS[0].radius = 6

    # will use: for color in unroll(["n_particles", "speed", "radius"]):
    elem = document.getElementById("n_particles")
    PARAMS[0].n_particles = elem.value
    elem.addEventListener("input", sync_slider["n_particles"])

    elem = document.getElementById("speed")
    PARAMS[0].speed = elem.value
    elem.addEventListener("input", sync_slider["speed"])

    elem = document.getElementById("radius")
    PARAMS[0].radius = elem.value
    elem.addEventListener("input", sync_slider["radius"])

    init_particles(PARAMS[0].speed, PARAMS[0].radius)
    window.requestAnimationFrame(frame)
)