dynamic_graph_field.kt
src/jvmMain/kotlin/afield/data/graph/dynamic_graph_field.kt · 473 lines
package and imports — 76 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Data")
package afield.data.graph
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.s
import afield.data.collections.times
import afield.document.context.Why
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.document.workspace.path
import afield.geometry.spatial.planeFigureHtml
import afield.geometry.spatial.showPlane
import afield.geometry.spatial.showPlaneGrid
import afield.geometry.spatial.toFLine
import afield.graphics.render.label
import afield.graphics.render.rangeTo
import afield.graphics.render.toFLine
import afield.graphics.text.toFLine
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.dot
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.channel
import afield.media.image.montage
import afield.media.image.toFLine
import afield.runtime.execution.go
import afield.runtime.lifecycle.once
import afield.simulation.agents.animateEvolution
import afield.simulation.agents.rasterizeFLines
import afield.simulation.agents.rasterizeFLinesFramed
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.audio.synth.*
import afield.data.collections.r
import afield.graphics.render.color
import afield.graphics.render.filled
import afield.graphics.render.fline
import afield.graphics.render.lineTo
import afield.graphics.render.moveTo
import afield.graphics.render.pointSize
import afield.graphics.render.pointed
import afield.graphics.render.stroked
import afield.graphics.render.thickness
import afield.graphics.vulkan.buffers.add
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import afield.math.linalg.i
import afield.math.linalg.vec
import afield.math.linalg.x
import afield.media.image.Img
import afield.media.image.colormap
import afield.app.protocol.Rect
import afield.geometry.path.FLine
import afield.graphics.color.Colormaps
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
The two useful ways to look at dynamic_graph_systems.kt — the point cloud and the weighted edges
joining it, so a time-varying graph can be watched rather than described.
dynamic_graph_systems.kt imports nothing but kotlin.math: every mention of Img, FLine or
Field's visualizer in this family is here.
Which of the two to use
| you want | call | why |
|---|---|---|
| a figure — one state, on a page, in a box | dynamicGraphFLines → Box.showPlaneGrid |
vector: real FLines through drawToSVG, so it scales, and edge weight drives colour and thickness |
| a frame of an animation | dynamicGraphPanel → Box.animateEvolution |
raster: Box.animateEvolution encodes a montage of Imgs per frame, and there is no vector path into an mp4 |
dynamicGraphFLines is the default, because a figure here is FLine →
planeFigureHtml/drawToSVG rather than a scan-converted pixel buffer. The raster path is for
clips: Box.animateEvolution's panels lambda is typed List<Pair<String, Img>>
(alife_animate.kt), so pixels are what it will take. Outside an animateEvolution,
dynamicGraphPanel is the wrong call.
The range policy, which applies to both paths:
- The world window is the caller's, and it is fixed. dynamicGraphImg takes an explicit
window, andBox.showPlaneGridtakes one too, rather than fitting to the cloud: a cloud that is moving and growing re-fitted every frame appears to stand still while the world shrinks around it. dynamicGraphWindow computes a window once, from a reference state, for you to keep; dynamicGraphPlaneWindow converts it for the vector path. - Edge weight is read against a
maxWeightnamed by the caller, not normalised per frame or per panel. Per-frame normalisation makes a graph that is uniformly saturating look identical to one that is uniformly dying. - Points are drawn at the top of the ramp and edges below it, so the population is legible even where the graph has decayed to nothing — an empty frame and a frame of isolated points are different states and must look different.
- A figure that clips says so. dynamicGraphImg reports how many points fell outside the
window in
DynamicGraphRaster.clipped, and dynamicGraphOutsideWindow answers the same question for the vector path.
data class DynamicGraphRaster
What dynamicGraphImg drew into one animation frame, and what it had to leave out.
data class DynamicGraphRaster(
/** 1-channel intensity in `0 .. 1`: `1` at a point, `weight / maxWeight` along an edge. */
val img: Img,
/** Points that fell outside the window and were not drawn. */
val clipped: Int,
/** The heaviest edge actually drawn, in weight units — for a caption that names its own scale. */
val heaviestWeight: Double
)
fun dynamicGraphWindow
A world window [xMin, yMin, xMax, yMax] around points, padded by padFraction of the larger
span and forced square so the aspect ratio of the raster matches the aspect ratio of the world.
Compute this once, from the state you want the figure framed on, and pass the same array to every frame. See the range policy on this file for why.
fun dynamicGraphWindow(
points: List<Vec3>,
padFraction: Double = 0.12,
project: (Vec3) -> Pair<Double, Double> = { it.x.toDouble() to it.y.toDouble() }
): DoubleArray {
require(points.isNotEmpty()) { "dynamicGraphWindow: needs at least one point" }
var xMin = Double.POSITIVE_INFINITY
var yMin = Double.POSITIVE_INFINITY
var xMax = Double.NEGATIVE_INFINITY
var yMax = Double.NEGATIVE_INFINITY
for (p in points) {
val (x, y) = project(p)
xMin = min(xMin, x); xMax = max(xMax, x)
yMin = min(yMin, y); yMax = max(yMax, y)
}
val cx = 0.5 * (xMin + xMax)
val cy = 0.5 * (yMin + yMax)
// Square, so the raster does not stretch the world on one axis.
val half = max(1e-9, 0.5 * max(xMax - xMin, yMax - yMin)) * (1.0 + padFraction)
return doubleArrayOf(cx - half, cy - half, cx + half, cy + half)
}
The figure path — FLines, for Box.showPlaneGrid
fun dynamicGraphFLines
A dynamic graph as vector geometry: one two-node polyline per edge, coloured and thickened by
that edge's weight, followed by one dot-drawn FLine carrying every point.
Hand the result to Box.showPlaneGrid (or Box.showPlane, or planeFigureHtml for the markup
alone) with dynamicGraphPlaneWindow as its window, and the figure is real SVG — it scales, the
strokes are anti-aliased, and the panel titles are HTML text rather than a bitmap font:
val window = dynamicGraphWindow(cloud, padFraction = 0.06)
showPlaneGrid(
"kNN k=5: ${edges.values().size}e" to dynamicGraphFLines(state.points, edges.values(), maxWeight = 4.0),
columns = 3, cellWidth = 400, cellHeight = 400, window = dynamicGraphPlaneWindow(window),
)
Why one FLine per edge, against this codebase's usual advice
PointGraph.toFLine draws a whole graph as a single FLine on the argument that "a graph is a
single visual layer", and that is right for an unweighted graph. It cannot be done here.
FLine.color and FLine.thickness are per-line properties, and outputTo in someSkiko.kt reads
exactly one Paint per FLine (someSkiko.kt:359-361) — so a weight that varies edge to edge can
only reach the page as one line per edge. The cost is real and worth knowing before you point this
at a big graph: measured, an edge is about 163 bytes of inline SVG (688 edges, 112 006 chars,
pinned by theEmittedSvgCostsAboutWhatTheKDocSaysPerEdge), so figure 6's densest 1300-edge panel is
a little over 200 kB. Fine for a figure; not fine 200 times a second. That is the other reason the
mp4 path stayed raster.
Coordinates: y is negated, once, here
SVG's y axis grows downward, and planeFigureHtml maps the caller's coordinates onto the frame
without flipping anything, so geometry handed to it straight would come out mirrored — the reverse
of the same cloud in the animated panels, and the wrong chirality for a phyllotaxis spiral. So this
function negates y after project, exactly as dynamicGraphImg's py() does, and
dynamicGraphPlaneWindow negates the window to match. project is therefore the same world → plane
map in both paths; do not pre-flip in it.
@param maxWeight the weight that maps to the top of the ramp and the thickest stroke; name it in the
figure's caption. The natural choice is the maxWeight the DynamicGraphSystem was given, since
that is the ceiling DynamicEdgeList.reinforce saturates at. Not derived from edges
— see the range policy at the top of this file.
@param colormap a ramp name from Colormaps.names; the same ramp dynamicGraphPanel colours with,
so a still and a clip of the same system read the same way
@param minEdgeIntensity where on the ramp a weight of ~0 sits, so a fresh faint edge is visible at
all against the dark card. Identical in meaning and default to dynamicGraphImg's.
@param thinEdge stroke width, in final figure pixels, at weight 0
@param thickEdge stroke width at maxWeight — so weight is encoded twice, redundantly, which is
what makes a printed or colour-blind reading of the figure survive
@param pointColor the dots' colour; null takes the top of colormap, which is where
dynamicGraphImg puts its points and why an isolated point is still visible
@param pointSize a dot's radius is 3 * pointSize output pixels (planeFigureHtml's convention),
so the default 0.5 matches the raster path's pointRadius = 1
@param project world -> plane; the default drops z, which is right for the planar clouds this
family's examples use. Pass your own for an isometric or a different pair of axes.
body — 59 lines, from line 214
fun dynamicGraphFLines(
points: List<Vec3>,
edges: Collection<DynamicGraphEdge>,
maxWeight: Double = 1.0,
colormap: String = "magma",
minEdgeIntensity: Double = 0.12,
thinEdge: Double = 0.8,
thickEdge: Double = 1.8,
pointColor: Vec4? = null,
pointSize: Double = 0.5,
project: (Vec3) -> Pair<Double, Double> = { it.x.toDouble() to it.y.toDouble() }
): List<FLine> {
require(maxWeight > 0.0) { "dynamicGraphFLines: maxWeight must be > 0, found $maxWeight" }
require(minEdgeIntensity in 0.0..1.0) {
"dynamicGraphFLines: minEdgeIntensity must be in 0..1, found $minEdgeIntensity"
}
require(thinEdge > 0.0 && thickEdge > 0.0) {
"dynamicGraphFLines: stroke widths must be > 0, found $thinEdge..$thickEdge"
}
require(pointSize >= 0.0) { "dynamicGraphFLines: pointSize must be >= 0, found $pointSize" }
// Project once. y is negated here and nowhere else; see the KDoc above.
val plane = points.map { val (x, y) = project(it); x to -y }
val out = ArrayList<FLine>(edges.size + 1)
for (e in edges) {
if (e.i !in plane.indices || e.j !in plane.indices) continue
val t = (e.weight / maxWeight).coerceIn(0.0, 1.0)
val ramp = minEdgeIntensity + (1.0 - minEdgeIntensity) * t
val c = Colormaps.sample(colormap, ramp)
val (ax, ay) = plane[e.i]
val (bx, by) = plane[e.j]
out.add(
fline {
moveTo(ax, ay)
lineTo(bx, by)
this.color = vec(c.r, c.g, c.b, 1.0)
this.thickness = thinEdge + (thickEdge - thinEdge) * t
this.stroked = true
this.filled = false
}
)
}
if (plane.isNotEmpty() && pointSize > 0.0) {
val top = Colormaps.sample(colormap, 1.0)
out.add(
fline {
plane.forEach { (x, y) -> moveTo(x, y) }
this.color = pointColor ?: vec(top.r, top.g, top.b, 0.95)
this.pointed = true
this.pointSize = pointSize
this.stroked = false
this.filled = false
}
)
}
return out
}
fun dynamicGraphPlaneWindow(window: DoubleArray): Rect
A dynamicGraphWindow result as the Rect Box.showPlaneGrid and planeFigureHtml want, with y
negated so it frames what dynamicGraphFLines actually emitted.
Pass it to every panel of a comparison. Without it each panel fits to its own contents, so two graphs over the same cloud are drawn at two different scales and the comparison is worthless — the exact failure the range policy at the top of this file is about, in its vector form.
fun dynamicGraphPlaneWindow(window: DoubleArray): Rect {
require(window.size == 4) { "dynamicGraphPlaneWindow: window must be [xMin, yMin, xMax, yMax]" }
require(window[2] > window[0] && window[3] > window[1]) {
"dynamicGraphPlaneWindow: window must be non-degenerate, got ${window.toList()}"
}
// y is negated, so [yMin, yMax] becomes [-yMax, -yMin] and the Rect's origin is -yMax.
return Rect(window[0], -window[3], window[2] - window[0], window[3] - window[1])
}
fun dynamicGraphOutsideWindow
How many of points fall outside window — so a caption can say it.
The vector path has no natural place to report this: dynamicGraphFLines emits geometry and it is
planeFigureHtml's frame that does the cropping, silently. dynamicGraphImg reports the same
number in DynamicGraphRaster.clipped. A figure that hides part of its subject and does not admit
it is the failure this exists to prevent.
fun dynamicGraphOutsideWindow(
points: List<Vec3>,
window: DoubleArray,
project: (Vec3) -> Pair<Double, Double> = { it.x.toDouble() to it.y.toDouble() }
): Int {
require(window.size == 4) { "dynamicGraphOutsideWindow: window must be [xMin, yMin, xMax, yMax]" }
return points.count {
val (x, y) = project(it)
x < window[0] || x > window[2] || y < window[1] || y > window[3]
}
}
The animation path — Img frames, for Box.animateEvolution
fun dynamicGraphImg
Rasterise a dynamic graph into one frame of an animation: every edge as a scan-converted line whose brightness is its weight, every point as a dot at full intensity.
This is not the way to draw a figure — dynamicGraphFLines is. It exists because
Box.animateEvolution is typed on Img (panels: (S, Int) -> List<Pair<String, Img>>, see
alife_animate.kt) and there is no vector path from an FLine into an mp4: the docs runner records
a box's PNG output channel, so a clip is pixels by construction. Two consequences worth knowing:
- It cannot be replaced by
rasterizeFLines/rasterizeFLinesFramed one family over, even though those also turn lines into anImg. That rasteriser plots a constant1f(alife_lsystem.kt:538), so it is a binary mask — every edge the same brightness, and the weight, which is the whole subject of this family, gone. - It uses
maxrather than an additive plot, so a dense knot of faint edges cannot impersonate one heavy edge.
The result is 1-channel and in 0 .. 1, so Img.colormap with an explicit 0f, 1f range is the
intended next step — dynamicGraphPanel is that pairing — and the colour ramp then means the same
thing in every frame of a sequence.
@param window [xMin, yMin, xMax, yMax] in world units — from dynamicGraphWindow, computed once
@param maxWeight the weight that maps to full edge brightness; name it in the figure's caption. The
natural choice is the maxWeight the DynamicGraphSystem was given, since that is the ceiling
DynamicEdgeList.reinforce saturates at.
@param minEdgeIntensity the brightness a weight of ~0 gets, so a fresh faint edge is visible at all
@param project world -> plane; the default drops z, which is right for the planar clouds this
family's examples use. Pass your own for an isometric or a different pair of axes.
body — 77 lines, from line 343
fun dynamicGraphImg(
points: List<Vec3>,
edges: Collection<DynamicGraphEdge>,
window: DoubleArray,
width: Int = 480,
height: Int = 480,
maxWeight: Double = 1.0,
pointRadius: Int = 1,
minEdgeIntensity: Float = 0.12f,
project: (Vec3) -> Pair<Double, Double> = { it.x.toDouble() to it.y.toDouble() }
): DynamicGraphRaster {
require(width >= 8 && height >= 8) { "dynamicGraphImg: raster must be at least 8x8, got ${width}x$height" }
require(window.size == 4) { "dynamicGraphImg: window must be [xMin, yMin, xMax, yMax]" }
require(window[2] > window[0] && window[3] > window[1]) {
"dynamicGraphImg: window must be non-degenerate, got ${window.toList()}"
}
require(maxWeight > 0.0) { "dynamicGraphImg: maxWeight must be > 0, found $maxWeight" }
require(pointRadius >= 0) { "dynamicGraphImg: pointRadius must be >= 0, found $pointRadius" }
val out = Img.zeros(width, height, 1)
val sx = (width - 1) / (window[2] - window[0])
val sy = (height - 1) / (window[3] - window[1])
fun px(x: Double) = ((x - window[0]) * sx).roundToInt()
// y downward, so the figure is the right way up when it is displayed.
fun py(y: Double) = (height - 1 - (y - window[1]) * sy).roundToInt()
// Additive would let a dense knot of faint edges impersonate one heavy edge, so this is a max.
fun plotMax(xi: Int, yi: Int, v: Float) {
if (xi in 0 until width && yi in 0 until height) {
val at = yi * width + xi
if (v > out.data[at]) out.data[at] = v
}
}
fun line(x0i: Int, y0i: Int, x1i: Int, y1i: Int, v: Float) {
var x0 = x0i
var y0 = y0i
val dx = abs(x1i - x0)
val sxi = if (x0 < x1i) 1 else -1
val dy = -abs(y1i - y0)
val syi = if (y0 < y1i) 1 else -1
var err = dx + dy
while (true) {
plotMax(x0, y0, v)
if (x0 == x1i && y0 == y1i) break
val e2 = 2 * err
if (e2 >= dy) { err += dy; x0 += sxi }
if (e2 <= dx) { err += dx; y0 += syi }
}
}
val screen = points.map { val (x, y) = project(it); px(x) to py(y) }
var heaviest = 0.0
for (e in edges) {
if (e.i !in screen.indices || e.j !in screen.indices) continue
heaviest = max(heaviest, e.weight)
val t = (e.weight / maxWeight).coerceIn(0.0, 1.0)
val v = minEdgeIntensity + (1f - minEdgeIntensity) * t.toFloat()
val (ax, ay) = screen[e.i]
val (bx, by) = screen[e.j]
line(ax, ay, bx, by, v)
}
var clipped = 0
for ((x, y) in screen) {
if (x !in 0 until width || y !in 0 until height) { clipped++; continue }
for (dy in -pointRadius..pointRadius) {
for (dx in -pointRadius..pointRadius) {
if (dx * dx + dy * dy <= pointRadius * pointRadius) plotMax(x + dx, y + dy, 1f)
}
}
}
return DynamicGraphRaster(out, clipped, heaviest)
}
fun dynamicGraphPanel
dynamicGraphImg straight through Img.colormap with a fixed 0 .. 1 range — the panel of an
animation frame, not a figure.
This is what the two clips on dynamic_graph_systems_examples.md pass to Box.animateEvolution's
panels, and it is the only thing it is for. The page's still figures go through
dynamicGraphFLines and Box.showPlaneGrid instead; if you are writing a new still, so should you.
Beyond the Img shape Box.animateEvolution needs, it exists so a call site does not accidentally
leave the colormap auto-ranging, which is the mistake the range policy at the top of this file is
about — and which in an animation is a per-frame lie rather than a per-figure one.
fun dynamicGraphPanel(
points: List<Vec3>,
edges: Collection<DynamicGraphEdge>,
window: DoubleArray,
width: Int = 480,
height: Int = 480,
maxWeight: Double = 1.0,
colormap: String = "magma",
pointRadius: Int = 1,
project: (Vec3) -> Pair<Double, Double> = { it.x.toDouble() to it.y.toDouble() }
): Img = dynamicGraphImg(
points, edges, window, width, height, maxWeight, pointRadius, project = project
).img.colormap(colormap, 0f, 1f)
fun dynamicGraphSummary(state: DynamicPointState, edges: DynamicEdgeList): String
What a dynamic-graph figure needs in its caption: point and edge counts, the weight range and mean, mean degree, how many points are isolated, and the state's clock.
One line, so a montage panel label or a println can carry the state of the system it is
drawing.
fun dynamicGraphSummary(state: DynamicPointState, edges: DynamicEdgeList): String {
val weights = edges.values().map { it.weight }
val degrees = IntArray(state.pointCount)
for (e in edges.values()) {
if (e.i in degrees.indices) degrees[e.i]++
if (e.j in degrees.indices && e.j != e.i) degrees[e.j]++
}
val isolated = degrees.count { it == 0 }
return buildString {
append("${state.pointCount} pts, ${weights.size} edges")
if (weights.isNotEmpty()) {
append(", w %.3f..%.3f".format(weights.min(), weights.max()))
append(" mean %.3f".format(weights.average()))
}
append(", mean deg %.2f".format(if (state.pointCount == 0) 0.0 else degrees.average()))
if (isolated > 0) append(", $isolated isolated")
append(", t=%.2fs".format(state.time))
}
}