Mass objects
A scatter plot of 10,000 samples, a particle cloud or a flow field would mean thousands of
Python objects if each point were a k.Dot. That makes the build slow, the scene graph
huge and the render needlessly expensive. kinemo has three vectorized types instead.
Each one is a single object that stores arrays and is drawn in batches by the native
core:
| Type | What it draws |
|---|---|
k.Points |
Many dots, with per-point radius and color |
k.VectorField |
Arrows of a field (x, y) → (vx, vy) on a grid, sized and colored by magnitude |
k.StreamLines |
Lines that follow a field, integrated with RK4 in the core |
They are ordinary objects. They enter with k.draw/k.fade_in, accept .place, scale,
opacity and .to(), and their props accept signals.
k.Points#
k.Points(xy=None, radius=0.02, color=None, *, x=None, y=None, **props)
The coordinates can be:
- a numpy array of shape
(n, 2); - a list of
(x, y)pairs; - two columns with
x=andy=(Arrow columns from polars, pandas or pyarrow, numpy arrays or lists).
k.draw(pts) reveals the points in index order. pts.count (or len(pts)) is the number
of points.
import numpy as np
import kinemo as k
@k.scene
def cloud(s: k.Scene):
rng = np.random.default_rng(1)
xy = rng.normal(0, 1.5, size=(8000, 2))
pts = k.Points(
xy,
radius=lambda p: 0.015 + 0.02 * p.t,
color=lambda t, p: k.mix(k.BLUE, k.RED, k.clamp((p.x + 3) / 6, 0, 1)),
)
s.play(k.draw(pts), duration=2)
s.play(pts.to(scale=0.6), duration=1)
s.wait(0.5)
Columns from a DataFrame go in directly, without copying:
import polars as pl
import kinemo as k
STATIONS = pl.DataFrame({"lon": [-3.0, 0.0, 3.0, 1.5], "lat": [-1.0, 1.0, 0.0, -2.0]})
@k.scene
def stations(s: k.Scene):
pts = k.Points(x=STATIONS["lon"], y=STATIONS["lat"], radius=0.12, color=k.YELLOW)
s.play(k.draw(pts))
s.wait(0.5)
Moving the points#
xy is a prop that interpolates point by point. pts.to(xy=new) with the same number of
points moves each point to its new position:
import numpy as np
import kinemo as k
@k.scene
def regroup(s: k.Scene):
rng = np.random.default_rng(3)
scattered = rng.uniform((-6, -3), (6, 3), size=(2000, 2))
angles = np.linspace(0, 2 * np.pi, 2000)
circle = np.column_stack([2.5 * np.cos(angles), 2.5 * np.sin(angles)])
pts = k.Points(scattered, radius=0.03, color=lambda p: k.mix(k.TEAL, k.PINK, p.t))
s.play(k.fade_in(pts))
s.play(pts.to(xy=circle), duration=2)
s.wait(0.5)
Per-point values#
radius and color (and the color of fields) accept any of these forms:
| Form | Example | Evaluated |
|---|---|---|
| One value or signal for every point | radius=0.03, color=k.TEAL, radius=r_signal |
Natively |
| One value per point | radius=np.array([...]), color=[k.RED, k.BLUE, ...] |
Stored as a list |
| A function of the symbolic point | lambda p: 0.02 + 0.01 * p.t |
Traced once, then vectorized natively |
| A function of time and the point | lambda t, p: k.sin(p.x - t) |
Traced once, re-evaluated every frame natively |
k.python(fn, vectorized=True) |
fn(p) receives numpy arrays |
Called once, at build time |
The symbolic point p has:
| Attribute | Meaning |
|---|---|
p.x, p.y |
Coordinates |
p.xy |
The point as a vector |
p.index |
Position in the set (0-based) |
p.count |
Number of points |
p.t |
index / (count - 1), from 0 to 1 |
Per-point functions follow the same tracing rules as lambdas and .map. Use k functions
(k.sin, k.mix, k.where, k.clamp), not math.*, if or the built-in
min()/max(). A function that cannot be traced is K0310.
With lambda t, p:, t is the scene time, so values can change continuously:
import kinemo as k
@k.scene
def wave(s: k.Scene):
pts = k.Points(
[(x / 10 - 6, 0.0) for x in range(121)],
radius=lambda t, p: 0.04 + 0.03 * k.sin(p.x * 2 - t * 4),
color=k.TEAL,
)
s.add(pts)
s.wait(3)
When the logic really needs numpy (or another library), compute it once with a
vectorized k.python. The function receives a point whose attributes are numpy arrays
and must return one value per point:
import numpy as np
import kinemo as k
def bands(p) -> np.ndarray:
return np.where(np.hypot(p.x, p.y) < 2, 0.05, 0.02)
@k.scene
def rings(s: k.Scene):
xy = np.random.default_rng(0).uniform((-5, -3), (5, 3), size=(3000, 2))
pts = k.Points(xy, radius=k.python(bands, vectorized=True), color=k.TEAL)
s.play(k.fade_in(pts))
s.wait(0.5)
A non-vectorized k.python(fn) cannot run per point (that would mean one Python call per
point per frame), and it is K0310.
k.VectorField#
k.VectorField(fn, density=30, *, length=0.8, x_range=None, y_range=None, color=None)
Arrows of fn on a grid with density columns across the region (x_range × y_range,
default: the frame inset by 1 unit). The arrow length is length × cell size × |v| / max|v|,
and the color goes from the theme's accent to its secondary color by magnitude, unless you
pass color= (a value or a function of the point).
fn can be written in several ways. It is traced once and evaluated natively at every grid
point:
| Form | Example |
|---|---|
Function of x, y |
lambda x, y: (-y, x) |
| Function of time and position | lambda t, x, y: (k.sin(t + y), 0.3) |
| Function of the symbolic point | lambda p: (-p.y, p.x) |
import kinemo as k
@k.scene
def colored(s: k.Scene):
field = k.VectorField(
lambda p: (-p.y, p.x),
density=20,
length=0.6,
color=lambda p: k.mix(k.BLUE, k.YELLOW, k.clamp(p.x / 6 + 0.5, 0, 1)),
)
s.play(k.draw(field))
s.wait(0.5)
k.StreamLines#
k.StreamLines(field, seeds=200, *, step=0.05, steps=60, progress=1.0, tail=1.0, fade=0.0, x_range=None, y_range=None, color=None)
Lines that follow field, which is a k.VectorField or a function in any of the forms
above.
seedsis a count (deterministic, well-spread points over the region) or the starting points (an(n, 2)array or a list).- Each line advances
stepunits per RK4 step, for at moststepssteps, and stops at the region border or where the field vanishes. progress(0 to 1) draws the lines over time.tailis the fraction of each line visible behind its head, and the tail fades tofadeopacity.
import kinemo as k
@k.scene
def swirl(s: k.Scene):
field = k.VectorField(lambda x, y: (-y, x - 0.3 * y), density=24, x_range=(-6, 6), y_range=(-3.5, 3.5))
lines = k.StreamLines(field, seeds=150, progress=0.0, tail=0.4, fade=0.2)
s.play(k.draw(field), duration=1.5)
s.play(k.fade_in(lines), lines.to(progress=1), duration=3)
s.wait(0.5)
In s.play(k.fade_in(lines), lines.to(progress=1)), put the entrance first. The
animations run in parallel, but a .to() listed before the verb that brings the object in
is checked first and fails with K0101.
Seeds given as points let you choose where the flow starts:
import kinemo as k
@k.scene
def gusts(s: k.Scene):
field = k.VectorField(lambda t, x, y: (1 + 0.5 * k.sin(t * 2 + y), 0.3 * k.cos(x - t)), density=20)
seeds = [(-6.0, y / 2) for y in range(-6, 7)]
lines = k.StreamLines(field, seeds=seeds, steps=120, progress=0.0, tail=0.3)
s.add(field)
s.play(k.fade_in(lines), lines.to(progress=1), duration=3)
s.wait(1)
W0901: too many individual objects#
When one source line creates more than 1000 individual objects (a list comprehension of
k.Dot, or ax.scatter with thousands of points), lint W0901 warns and suggests the
vectorized type:
W0901 1200 Dot objects created on this line: use a vectorized type :6
fix: k.Points(xy, radius=0.02, color=lambda p: ...)
The rewrite usually takes a few lines: collect the coordinates into an array (or two columns) and move any per-object logic into a per-point function.
# Before: W0901
dots = [k.Dot(r=0.02, x=(i % 50) * 0.2 - 5, y=(i // 50) * 0.2 - 2) for i in range(1200)]
s.add(*dots)
# After: one object
xy = [((i % 50) * 0.2 - 5, (i // 50) * 0.2 - 2) for i in range(1200)]
s.add(k.Points(xy, radius=0.02))
Common mistakes#
Diagnostic What happened Fix W0901A loop created more than 1000 k.Dot(or other leaf objects) on one line.k.Points(xy, ...),k.VectorField,k.StreamLines.K0310A per-point lambda uses math.sin,iformin().k.sin,k.where,k.min, ork.python(fn, vectorized=True)with numpy.K0310radius=k.python(fn)withoutvectorized=True.Add vectorized=True:fnthen receives numpy arrays once, at build time.K0310A vectorized k.pythonreturned a different number of values than there are points.Return one value per point ( np.where(...)keeps the shape).K1201Coordinates passed both as xyand asx=/y=, or only one ofx=/y=.Pass xy, orx=andy=together.K0101s.play(lines.to(progress=1), k.fade_in(lines)): the.to()comes before the entrance.Put the entrance first: s.play(k.fade_in(lines), lines.to(progress=1)).
See also: k.Points,
k.VectorField,
k.StreamLines,
k.python, Charts and data.