curryparty 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.3
2
+ Name: curryparty
3
+ Version: 0.1.0
4
+ Summary: Python playground to learn lambda calculus
5
+ Author: Antonin P
6
+ Author-email: Antonin P <antonin.peronnet@telecom-paris.fr>
7
+ Requires-Dist: polars>=1.36.1
8
+ Requires-Dist: svg-py>=1.9.2
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Curry party
13
+
14
+ `curryparty` is a library created to explore, visualize and teach lambda-calculus concepts.
15
+
16
+ # Install
17
+
18
+ Run `pip install curryparty` or `uv add curryparty` depending on your package manager.
19
+
20
+ # How to use
21
+
22
+ ```python
23
+ from curryparty import L, V
24
+
25
+ # build expressions idiomatically
26
+ identity = L("x")._("x").build()
27
+
28
+ zero = L("f", "x")._("x").build()
29
+
30
+ omega = L("x")._("x").call("x").build()
31
+
32
+ # you can create more complex terms:
33
+
34
+ succ = L("n", "f", "x")._("f").call(
35
+ V("n").call("f").call("x")
36
+ )
37
+
38
+ # If you try to combine them, nothing will happen:
39
+ bomb = omega(omega)
40
+ one = s(zero)
41
+
42
+ # You need to beta-reduce them:
43
+ bomb.beta()
44
+ one_with_first_reduction = one.beta()
45
+
46
+ # if you want the final form:
47
+ term = s(zero)
48
+ while term is not None:
49
+ term = term.beta()
50
+ print(term)
51
+
52
+ # this is equivalent to:
53
+ for x in term.reduction_chain():
54
+ print(x)
55
+ ```
56
+
57
+ But the main point of this library is the svg-based display system.
58
+ If you use a notebook such as jupyternotebook or marimo, you will see something like this:
59
+
60
+ ![](./assets/notebook_display.png)
61
+
62
+
63
+ You can also use `term.show_reduction` to get an animated version.
64
+
65
+ # How it works
66
+
67
+ Under the wood, all the terms are converted into a list of nodes, that can either be a `lambda`, an `application` (with 2 arguments) or a `variable` (with 0 arguments).
68
+
69
+ For efficiency, they are converted to a [polars](https://github.com/pola-rs/polars) dataframe in prefix traversal order.
70
+ If you want to understand how it works, start by looking at the `.nodes` attribute of any term.
@@ -0,0 +1,59 @@
1
+ # Curry party
2
+
3
+ `curryparty` is a library created to explore, visualize and teach lambda-calculus concepts.
4
+
5
+ # Install
6
+
7
+ Run `pip install curryparty` or `uv add curryparty` depending on your package manager.
8
+
9
+ # How to use
10
+
11
+ ```python
12
+ from curryparty import L, V
13
+
14
+ # build expressions idiomatically
15
+ identity = L("x")._("x").build()
16
+
17
+ zero = L("f", "x")._("x").build()
18
+
19
+ omega = L("x")._("x").call("x").build()
20
+
21
+ # you can create more complex terms:
22
+
23
+ succ = L("n", "f", "x")._("f").call(
24
+ V("n").call("f").call("x")
25
+ )
26
+
27
+ # If you try to combine them, nothing will happen:
28
+ bomb = omega(omega)
29
+ one = s(zero)
30
+
31
+ # You need to beta-reduce them:
32
+ bomb.beta()
33
+ one_with_first_reduction = one.beta()
34
+
35
+ # if you want the final form:
36
+ term = s(zero)
37
+ while term is not None:
38
+ term = term.beta()
39
+ print(term)
40
+
41
+ # this is equivalent to:
42
+ for x in term.reduction_chain():
43
+ print(x)
44
+ ```
45
+
46
+ But the main point of this library is the svg-based display system.
47
+ If you use a notebook such as jupyternotebook or marimo, you will see something like this:
48
+
49
+ ![](./assets/notebook_display.png)
50
+
51
+
52
+ You can also use `term.show_reduction` to get an animated version.
53
+
54
+ # How it works
55
+
56
+ Under the wood, all the terms are converted into a list of nodes, that can either be a `lambda`, an `application` (with 2 arguments) or a `variable` (with 0 arguments).
57
+
58
+ For efficiency, they are converted to a [polars](https://github.com/pola-rs/polars) dataframe in prefix traversal order.
59
+ If you want to understand how it works, start by looking at the `.nodes` attribute of any term.
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "curryparty"
3
+ version = "0.1.0"
4
+ description = "Python playground to learn lambda calculus"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Antonin P", email = "antonin.peronnet@telecom-paris.fr" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "polars>=1.36.1",
12
+ "svg-py>=1.9.2",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.9.13,<0.10.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "marimo>=0.18.4",
22
+ "ruff>=0.14.8",
23
+ ]
@@ -0,0 +1,172 @@
1
+ from typing import Iterable, List, Optional, Union
2
+
3
+ import polars as pl
4
+ from svg import SVG
5
+
6
+ from .core import SCHEMA, beta_reduce, compose, find_redexes, find_variables, subtree
7
+ from .display import (
8
+ compute_svg_frame_final,
9
+ compute_svg_frame_init,
10
+ compute_svg_frame_phase_a,
11
+ compute_svg_frame_phase_b,
12
+ )
13
+ from .utils import ShapeAnim
14
+
15
+ __all__ = ["L", "V"]
16
+
17
+
18
+ class Term:
19
+ def __init__(self, nodes: pl.DataFrame):
20
+ assert nodes.schema == SCHEMA, (
21
+ f"{nodes.schema} is different from expected {SCHEMA}"
22
+ )
23
+ self.nodes = nodes
24
+ self.lamb = None
25
+
26
+ def __call__(self, other: "Term") -> "Term":
27
+ return Term(compose(self.nodes, other.nodes))
28
+
29
+ def beta(self) -> Optional["Term"]:
30
+ candidates = find_redexes(self.nodes)
31
+ if len(candidates) == 0:
32
+ return None
33
+ _redex, lamb, b = candidates.row(0)
34
+ self.lamb = lamb
35
+ self.b = b
36
+ reduced = beta_reduce(self.nodes, lamb, b)
37
+ return Term(reduced)
38
+
39
+ def reduction_chain(self) -> Iterable["Term"]:
40
+ term = self
41
+ while True:
42
+ yield term
43
+ term = term.beta()
44
+ if term is None:
45
+ break
46
+
47
+ def show_reduction(self):
48
+ candidates = find_redexes(self.nodes)
49
+ if len(candidates) == 0:
50
+ return None
51
+ _redex, lamb, b = candidates.row(0)
52
+ new_nodes = beta_reduce(self.nodes, lamb, b)
53
+ vars = find_variables(self.nodes, lamb)["id"]
54
+ b_subtree = subtree(self.nodes, b)
55
+ shapes: dict[int, ShapeAnim] = {}
56
+ N_STEPS = 8
57
+
58
+ for t in range(N_STEPS):
59
+ if t < 2:
60
+ items = compute_svg_frame_init(self.nodes)
61
+ elif t == 2 or t == 3:
62
+ items = compute_svg_frame_phase_a(self.nodes, lamb, b_subtree, vars)
63
+ elif t == 4 or t == 5:
64
+ items = compute_svg_frame_phase_b(
65
+ self.nodes, lamb, b_subtree, new_nodes
66
+ )
67
+ else:
68
+ items = compute_svg_frame_final(new_nodes)
69
+ for k, e, attributes in items:
70
+ if k not in shapes:
71
+ shapes[k] = ShapeAnim(e)
72
+ shapes[k].append_frame(t, attributes.items())
73
+
74
+ elements = [x.to_element(N_STEPS) for x in shapes.values()]
75
+ height = 2 + int(0.6 * len(self.nodes))
76
+ return Html(
77
+ SVG(
78
+ xmlns="http://www.w3.org/2000/svg",
79
+ viewBox=f"-10 0 30 {height}", # type: ignore
80
+ height=f"{35 * height}px", # type: ignore
81
+ elements=elements,
82
+ ).as_str()
83
+ )
84
+
85
+ def _repr_html_(self):
86
+ frame = compute_svg_frame_init(self.nodes)
87
+ elements = []
88
+ height = 2 + int(0.6 * len(self.nodes))
89
+ for _, e, attributes in frame:
90
+ for name, v in attributes.items():
91
+ e.__setattr__(name, v)
92
+ elements.append(e)
93
+ return SVG(
94
+ xmlns="http://www.w3.org/2000/svg",
95
+ viewBox=f"-10 0 30 {height}", # type: ignore
96
+ height=f"{35 * height}px", # type: ignore
97
+ elements=elements,
98
+ ).as_str()
99
+
100
+
101
+ class L:
102
+ def __init__(self, *lambda_names):
103
+ self.n = len(lambda_names)
104
+ self.lambdas = {x: i for (i, x) in enumerate(lambda_names)}
105
+ self.refs: List[tuple[int, Union[int, str]]] = []
106
+ self.args = []
107
+ self.last_ = None
108
+
109
+ def lamb(self, name: str) -> "L":
110
+ self.n += 1
111
+ self.lambdas[name] = self.n
112
+ return self
113
+
114
+ def _append_subtree_or_subexpression(self, t: Union[str, "L"]):
115
+ if isinstance(t, L):
116
+ offset = self.n
117
+ for i, x in t.refs:
118
+ self.refs.append((offset + i, t.lambdas.get(x, x)))
119
+
120
+ for i, x in t.args:
121
+ self.args.append((offset + i, offset + x))
122
+ self.n += t.n
123
+ else:
124
+ assert isinstance(t, str)
125
+ self.refs.append((self.n, t))
126
+ self.n += 1
127
+
128
+ def _(self, x: Union[str, "L"]) -> "L":
129
+ self.last_ = self.n
130
+ self._append_subtree_or_subexpression(x)
131
+ return self
132
+
133
+ def call(self, arg: Union[str, "L"]) -> "L":
134
+ assert self.last_ is not None
135
+ self.refs = [(i + 1, x) if i >= self.last_ else (i, x) for (i, x) in self.refs]
136
+ self.args = [
137
+ (i + 1, x + 1) if i >= self.last_ else (i, x) for (i, x) in self.args
138
+ ]
139
+
140
+ self.n += 1
141
+ self.args.append((self.last_, self.n))
142
+ self._append_subtree_or_subexpression(arg)
143
+
144
+ return self
145
+
146
+ def build(self) -> "Term":
147
+ self.refs = [(i, self.lambdas.get(x, x)) for i, x in self.refs]
148
+ ref = pl.from_records(
149
+ self.refs, orient="row", schema={"id": pl.UInt32, "ref": pl.UInt32}
150
+ )
151
+ arg = pl.from_records(
152
+ self.args, orient="row", schema={"id": pl.UInt32, "arg": pl.UInt32}
153
+ )
154
+ data = (
155
+ pl.Series("id", range(self.n), dtype=pl.UInt32)
156
+ .to_frame()
157
+ .join(ref, on="id", how="left")
158
+ .join(arg, on="id", how="left")
159
+ ).with_columns(bid=pl.struct(major="id", minor="id"))
160
+ return Term(data)
161
+
162
+
163
+ def V(name: str) -> L:
164
+ return L()._(name)
165
+
166
+
167
+ class Html:
168
+ def __init__(self, content: str):
169
+ self.content = content
170
+
171
+ def _repr_html_(self):
172
+ return self.content
@@ -0,0 +1,128 @@
1
+ import polars as pl
2
+ from polars import Schema, UInt32
3
+ from polars.functions.lazy import coalesce
4
+
5
+ SCHEMA = Schema(
6
+ {
7
+ "id": UInt32,
8
+ "ref": UInt32,
9
+ "arg": UInt32,
10
+ "bid": pl.Struct({"major": UInt32, "minor": UInt32}),
11
+ },
12
+ )
13
+
14
+
15
+ def _shift(nodes: pl.DataFrame, offset: int):
16
+ return nodes.with_columns(
17
+ pl.col("id") + offset,
18
+ pl.col("ref") + offset,
19
+ pl.col("arg") + offset,
20
+ bid=None,
21
+ )
22
+
23
+
24
+ def compose(f: pl.DataFrame, x: pl.DataFrame):
25
+ n = len(f)
26
+ return pl.concat(
27
+ [
28
+ pl.DataFrame(
29
+ [{"id": 0, "arg": n + 1}],
30
+ schema=SCHEMA,
31
+ ),
32
+ _shift(f, 1),
33
+ _shift(x, n + 1),
34
+ ],
35
+ )
36
+
37
+
38
+ def find_redexes(nodes: pl.DataFrame):
39
+ parents = nodes.filter(pl.col("ref").is_null())
40
+ return (
41
+ parents.join(nodes, left_on="id", right_on=pl.col("id") - 1, suffix="_child")
42
+ .filter(
43
+ pl.col("arg").is_not_null(),
44
+ pl.col("ref_child").is_null(),
45
+ pl.col("arg_child").is_null(),
46
+ )
47
+ .select(redex="id", lamb="id_child", b="arg")
48
+ )
49
+
50
+
51
+ def find_variables(nodes: pl.DataFrame, lamb: int):
52
+ return nodes.filter(pl.col("ref") == lamb).select("id", replaced=True)
53
+
54
+
55
+ def subtree(nodes: pl.DataFrame, root: int) -> pl.DataFrame:
56
+ rightmost = root
57
+ while True:
58
+ ref = nodes["ref"][rightmost]
59
+ arg = nodes["arg"][rightmost]
60
+ if ref is not None:
61
+ return nodes.filter(pl.col("id").is_between(root, rightmost))
62
+
63
+ rightmost = arg if arg is not None else rightmost + 1
64
+
65
+
66
+ def _generate_bi_identifier(
67
+ major_name: str, minor_name: str, minor_replacement=pl.lit(None)
68
+ ):
69
+ return pl.struct(
70
+ major=pl.col(major_name).fill_null(pl.col(minor_name)),
71
+ minor=minor_replacement.fill_null(pl.col(minor_name)),
72
+ )
73
+
74
+
75
+ def beta_reduce(nodes: pl.DataFrame, lamb: int, b: int) -> pl.DataFrame:
76
+ redex = lamb - 1
77
+ a = lamb + 1
78
+
79
+ vars = find_variables(nodes, lamb)
80
+ b_subtree = subtree(nodes, b)
81
+
82
+ b_subtree_duplicated = b_subtree.join(vars, how="cross", suffix="_major")
83
+ rest_of_nodes = nodes.join(b_subtree, on="id", how="anti").with_columns(
84
+ arg=pl.col("arg").replace(redex, a)
85
+ )
86
+
87
+ new_nodes = (
88
+ pl.concat(
89
+ [b_subtree_duplicated, rest_of_nodes],
90
+ how="diagonal_relaxed",
91
+ )
92
+ .join(vars, left_on="id", right_on="id", how="anti")
93
+ .join(vars, left_on="arg", right_on="id", how="left", suffix="_arg")
94
+ .join(vars, left_on="ref", right_on="id", how="left", suffix="_ref")
95
+ .filter(
96
+ ~pl.col("id").is_between(redex, lamb),
97
+ )
98
+ .select(
99
+ bid=_generate_bi_identifier("id_major", "id"),
100
+ bid_ref=_generate_bi_identifier("id_major", "ref"),
101
+ bid_ref_fallback=pl.struct(major="ref", minor="ref"),
102
+ bid_arg=_generate_bi_identifier(
103
+ "id_major", "arg", minor_replacement=pl.when("replaced_arg").then(b)
104
+ ),
105
+ )
106
+ .sort("bid")
107
+ .with_row_index("id")
108
+ )
109
+
110
+ return (
111
+ new_nodes.join(
112
+ new_nodes.select(bid_ref="bid", ref="id"),
113
+ on="bid_ref",
114
+ how="left",
115
+ )
116
+ .join(
117
+ new_nodes.select(bid_ref_fallback="bid", ref_fallback="id"),
118
+ on="bid_ref_fallback",
119
+ how="left",
120
+ )
121
+ .join(
122
+ new_nodes.select(bid_arg="bid", arg="id"),
123
+ on="bid_arg",
124
+ how="left",
125
+ )
126
+ .select("id", ref=pl.coalesce("ref", "ref_fallback"), arg="arg", bid="bid")
127
+ .sort("id")
128
+ )
@@ -0,0 +1,232 @@
1
+ from typing import Any, Iterable, Optional, Union
2
+
3
+ import polars as pl
4
+ import svg
5
+
6
+ from .utils import Interval
7
+
8
+
9
+ def compute_layout(
10
+ nodes: pl.DataFrame, lamb=None, replaced_var_width=1
11
+ ) -> tuple[dict[int, int], dict[int, int]]:
12
+ y = {0: Interval((0, 0))}
13
+ x = {}
14
+ for node, ref, arg in nodes.select("id", "ref", "arg").iter_rows():
15
+ if ref is not None:
16
+ continue
17
+ child = node + 1
18
+ if arg is not None:
19
+ y[child] = y[node].shift(0 if nodes["arg"][child] is None else 1)
20
+ y[arg] = y[node].shift(0)
21
+ else:
22
+ y[child] = y[node].shift(1)
23
+
24
+ next_var_x = nodes.select(pl.col("ref").count()).item()
25
+
26
+ for node, ref, arg in (
27
+ nodes.sort("id", descending=True).select("id", "ref", "arg").iter_rows()
28
+ ):
29
+ if ref is not None:
30
+ width = replaced_var_width if ref == lamb else 1
31
+ x[node] = Interval((next_var_x - width + 1, next_var_x))
32
+ next_var_x -= width
33
+ x[ref] = x[node] | x.get(ref, Interval(None))
34
+
35
+ else:
36
+ child = node + 1
37
+ x[node] = x[child] | x.get(node, Interval(None))
38
+ y[node] = y[child] | y[node]
39
+ return x, y
40
+
41
+
42
+ def draw(
43
+ x: dict[Union[int, tuple[int, int]], Interval],
44
+ y: dict[Union[int, tuple[int, int]], Interval],
45
+ i_node: Union[int, tuple[int, int]],
46
+ ref: Optional[int],
47
+ arg: Optional[int],
48
+ key: Any,
49
+ replaced=False,
50
+ removed=False,
51
+ ) -> Iterable[tuple[Any, svg.Element, dict]]:
52
+ x_node = x[i_node]
53
+ y_node = y[i_node]
54
+ if True:
55
+ if arg is not None or removed:
56
+ color = "transparent"
57
+ elif replaced or removed:
58
+ color = "green"
59
+ elif ref is not None:
60
+ color = "red"
61
+ else:
62
+ color = "blue"
63
+
64
+ stroke_width = 0.05
65
+ stroke = "gray"
66
+ if arg is not None:
67
+ stroke_width = 0.1
68
+ stroke = "orange"
69
+ r = svg.Rect(
70
+ height=0.8,
71
+ stroke_width=stroke_width,
72
+ stroke=stroke,
73
+ )
74
+
75
+ yield (
76
+ ("r", key),
77
+ r,
78
+ {
79
+ "x": 0.1 + x_node[0],
80
+ "y": 0.1 + y_node[0],
81
+ "width": 0.8 + x_node[1] - x_node[0],
82
+ "fill_opacity": 1 if arg is None else 0,
83
+ "fill": color,
84
+ },
85
+ )
86
+
87
+ if ref is not None:
88
+ y_ref = y[ref]
89
+ e = svg.Line(
90
+ stroke_width=0.2,
91
+ stroke="gray",
92
+ )
93
+ yield (
94
+ ("l", key),
95
+ e,
96
+ {
97
+ "x1": x_node[0] + 0.5,
98
+ "y1": y_node[0] + 0.1,
99
+ "x2": x_node[0] + 0.5,
100
+ "y2": y_ref[0] + 0.9,
101
+ "stroke": "green" if replaced else "gray",
102
+ },
103
+ )
104
+
105
+ if arg is not None:
106
+ x_arg = x[arg]
107
+ y_arg = y[arg]
108
+ e1 = svg.Line(
109
+ stroke="black",
110
+ stroke_width=0.05,
111
+ )
112
+ e2 = svg.Circle(fill="black", r=0.1)
113
+ if not removed:
114
+ yield (
115
+ ("b", key),
116
+ e1,
117
+ {
118
+ "x1": 0.5 + x_node[1],
119
+ "y1": 0.5 + y_node[0],
120
+ "x2": 0.5 + x_arg[0],
121
+ "y2": 0.5 + y_arg[0],
122
+ },
123
+ )
124
+ yield (
125
+ ("c", key),
126
+ e2,
127
+ {
128
+ "cx": 0.5 + x_node[1],
129
+ "cy": 0.5 + y_node[0],
130
+ },
131
+ )
132
+
133
+
134
+ def compute_svg_frame_init(
135
+ nodes: pl.DataFrame,
136
+ ) -> Iterable[tuple[Any, svg.Element, dict[str, Any]]]:
137
+ x, y = compute_layout(nodes)
138
+ for target_id, ref, arg in (
139
+ nodes.select("id", "ref", "arg").sort("id", descending=True).iter_rows()
140
+ ):
141
+ yield from draw(x, y, target_id, ref, arg, target_id)
142
+
143
+
144
+ def compute_svg_frame_phase_a(
145
+ nodes: pl.DataFrame,
146
+ lamb: int,
147
+ b_subtree: pl.DataFrame,
148
+ vars: pl.Series,
149
+ ):
150
+ redex = lamb - 1 if lamb is not None else None
151
+ b_width = b_subtree.count()["ref"].item()
152
+ x, y = compute_layout(nodes, lamb=lamb, replaced_var_width=b_width)
153
+ for target_id, ref, arg in (
154
+ nodes.select("id", "ref", "arg").sort("id", descending=True).iter_rows()
155
+ ):
156
+ replaced = ref is not None and ref == lamb
157
+ yield from draw(
158
+ x,
159
+ y,
160
+ target_id,
161
+ ref,
162
+ arg,
163
+ target_id,
164
+ replaced=replaced,
165
+ removed=(target_id == lamb or target_id == redex),
166
+ )
167
+
168
+ for v in vars:
169
+ for minor, ref, arg in (
170
+ b_subtree.select("id", "ref", "arg").sort("id", descending=True).iter_rows()
171
+ ):
172
+ yield from draw(x, y, minor, ref, arg, key=(v, minor))
173
+
174
+
175
+ def compute_svg_frame_phase_b(
176
+ nodes: pl.DataFrame,
177
+ lamb: int,
178
+ b_subtree: pl.DataFrame,
179
+ new_nodes: pl.DataFrame,
180
+ ):
181
+ b_width = b_subtree.count()["ref"].item()
182
+ b = b_subtree["id"][0]
183
+ x, y = compute_layout(nodes, lamb=lamb, replaced_var_width=b_width)
184
+ b_x = x[b][0]
185
+ b_y = y[b][0]
186
+ for bid, arg in new_nodes.select("bid", "arg").iter_rows():
187
+ if bid["minor"] != bid["major"]:
188
+ v = bid["major"]
189
+ minor = bid["minor"]
190
+ delta_x = x[v][0] - b_x
191
+ delta_y = y[v][0] - b_y
192
+ x[(v, minor)] = x[minor].shift(delta_x)
193
+ y[(v, minor)] = y[minor].shift(delta_y)
194
+
195
+ for bid, new_ref, new_arg in new_nodes.select("bid", "ref", "arg").iter_rows():
196
+ v = bid["major"]
197
+ minor = bid["minor"]
198
+ if new_ref is None:
199
+ ref = None
200
+ else:
201
+ bid_ref = new_nodes["bid"][new_ref]
202
+ ref = (
203
+ (bid_ref["major"], bid_ref["minor"])
204
+ if bid_ref["major"] != bid_ref["minor"]
205
+ else bid_ref["minor"]
206
+ )
207
+ if new_arg is None:
208
+ arg = None
209
+ else:
210
+ bid_arg = new_nodes["bid"][new_arg]
211
+ arg = (
212
+ (bid_arg["major"], bid_arg["minor"])
213
+ if bid_arg["major"] != bid_arg["minor"]
214
+ else bid_arg["minor"]
215
+ )
216
+ if bid_arg["minor"] == b:
217
+ arg = bid_arg["major"]
218
+ key = (v, minor) if minor != v else minor
219
+ yield from draw(x, y, key, ref, arg, key=key)
220
+
221
+
222
+ def compute_svg_frame_final(reduced: pl.DataFrame):
223
+ x, y = compute_layout(reduced)
224
+ for target_id, bid, ref, arg in (
225
+ reduced.select("id", "bid", "ref", "arg")
226
+ .sort("id", descending=True)
227
+ .iter_rows()
228
+ ):
229
+ minor = bid["minor"]
230
+ major = bid["major"]
231
+ key = (major, minor) if minor != major else minor
232
+ yield from draw(x, y, target_id, ref, arg, key)
File without changes
@@ -0,0 +1,96 @@
1
+ from dataclasses import dataclass
2
+ from datetime import timedelta
3
+ from typing import Any, Iterable, Optional
4
+
5
+ from svg import Animate, Element
6
+
7
+
8
+ @dataclass
9
+ class Interval:
10
+ values: Optional[tuple[int, int]]
11
+
12
+ def __or__(self: "Interval", other: "Interval") -> "Interval":
13
+ if self.values is None:
14
+ if other.values is None:
15
+ return Interval(None)
16
+ else:
17
+ return other
18
+ else:
19
+ if other.values is None:
20
+ return self
21
+ return Interval(
22
+ (min(self.values[0], other.values[0]), max(self.values[1], other.values[1]))
23
+ )
24
+
25
+ def __getitem__(self, index):
26
+ assert self.values is not None, "interval is empty"
27
+ return self.values[index]
28
+
29
+ def shift(self, offset: int) -> "Interval":
30
+ if self.values is None:
31
+ return Interval(None)
32
+ else:
33
+ return Interval((self.values[0] + offset, self.values[1] + offset))
34
+
35
+
36
+ @dataclass
37
+ class ShapeAnim:
38
+ shape: Element
39
+ attributes: set[str]
40
+ values: dict[tuple[int, str], Any]
41
+ n: int
42
+ duration: int
43
+
44
+ def __init__(
45
+ self,
46
+ shape: Element,
47
+ duration: int = 7,
48
+ ):
49
+ self.shape = shape
50
+ self.attributes = set()
51
+ self.values = {}
52
+ self.duration = duration
53
+
54
+ def append_frame(self, i: int, attributes: Iterable[tuple[str, Any]]):
55
+ for name, v in attributes:
56
+ self.attributes.add(name)
57
+ self.values[i, name] = v
58
+
59
+ def to_element(self, n: int):
60
+ elements = []
61
+
62
+ visible = [
63
+ all((i, name) in self.values for name in self.attributes) for i in range(n)
64
+ ]
65
+
66
+ for name in self.attributes:
67
+ non_nulls = [
68
+ self.values[i, name] for i in range(n) if (i, name) in self.values
69
+ ]
70
+ for i in reversed(range(n)):
71
+ if (i, name) in self.values:
72
+ break
73
+ self.values[i, name] = non_nulls[-1]
74
+ for i in range(n):
75
+ if (i, name) not in self.values:
76
+ self.values[i, name] = non_nulls[0]
77
+ elements.append(
78
+ Animate(
79
+ attributeName=name,
80
+ values=";".join(str(self.values[i, name]) for i in range(n)),
81
+ dur=timedelta(seconds=self.duration),
82
+ repeatCount="indefinite",
83
+ )
84
+ )
85
+ elements.append(
86
+ Animate(
87
+ attributeName="opacity",
88
+ values=";".join("1" if v else "0" for v in visible),
89
+ dur=timedelta(seconds=self.duration),
90
+ repeatCount="indefinite",
91
+ )
92
+ )
93
+
94
+ assert not self.shape.elements
95
+ self.shape.elements = elements
96
+ return self.shape