Harden notebook animation rendering

This commit is contained in:
saymrwulf 2026-04-16 12:05:32 +02:00
parent 5d267ee5a3
commit b3b18857f0
2 changed files with 164 additions and 22 deletions

View file

@ -105,7 +105,7 @@ def _player_widget(
def render(index: int) -> None:
frame_html.value = f"""
<div style="width:100%; max-width:100%; overflow:hidden; background:#fff9f2; padding: 10px 14px 14px 14px; box-sizing:border-box;">
<div style="width:100%; max-width:100%; overflow-x:auto; overflow-y:hidden; background:#fff9f2; padding: 10px 14px 14px 14px; box-sizing:border-box;">
{frames[index]}
</div>
"""
@ -153,6 +153,55 @@ def _svg_text(x: float, y: float, text: str, *, size: int = 14, weight: str = "4
return f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-size="{size}" font-weight="{weight}" font-family="Avenir Next, Trebuchet MS, sans-serif" fill="{fill}">{escape(text)}</text>'
def _svg_multiline_text(
x: float,
y: float,
text: str,
*,
max_chars: int = 42,
line_height: int = 16,
size: int = 14,
weight: str = "400",
fill: str = SVG_INK,
anchor: str = "start",
) -> str:
words = text.split()
if not words:
return ""
lines: list[str] = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if len(candidate) <= max_chars:
current = candidate
else:
lines.append(current)
current = word
lines.append(current)
return "".join(
_svg_text(
x,
y + index * line_height,
line,
size=size,
weight=weight,
fill=fill,
anchor=anchor,
)
for index, line in enumerate(lines)
)
def _svg_canvas_open(width: int, height: int) -> str:
return (
f'<svg viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
f'preserveAspectRatio="xMinYMin meet" '
f'style="display:block;width:{width}px;max-width:none;min-width:{width}px;height:auto;background:{SVG_BG}; border-radius:0 0 14px 14px;">'
)
def _html_token(label: str, value: str, *, fill: str, border: str, text: str = SVG_INK) -> str:
return f"""
<div style="
@ -406,19 +455,19 @@ def _wrap_compare_frame_svg(coefficients: Sequence[int], n: int, step: int) -> s
current_index, current_value = flat[step]
cell = 58
width = max(980, len(coefficients) * cell + 200)
height = 390
top_y = 78
cyclic_y = 210
neg_y = 300
height = 450
top_y = 118
cyclic_y = 252
neg_y = 348
parts = [
f'<svg viewBox="0 0 {width} {height}" preserveAspectRatio="xMidYMin meet" width="100%" style="display:block;width:100%;max-width:100%;height:auto;background:{SVG_BG}; border-radius:0 0 14px 14px;">',
_svg_canvas_open(width, height),
f'<rect x="18" y="18" width="{width - 36}" height="{height - 36}" rx="18" fill="{SVG_PANEL}" stroke="#e8dcc9" stroke-width="2"></rect>',
_svg_text(34, 48, "Wraparound Comparison Player", size=22, weight="800"),
_svg_text(34, 68, f"Current source term: x^{current_index} with coefficient {current_value}", size=13, fill="#486581"),
_svg_text(34, top_y - 14, "Raw convolution tail", size=14, weight="700"),
_svg_text(34, cyclic_y - 14, "Cyclic fold into x^n - 1", size=14, weight="700", fill=SVG_BLUE),
_svg_text(34, neg_y - 14, "Negacyclic fold into x^n + 1", size=14, weight="700", fill=SVG_ACCENT),
_svg_text(34, 74, f"Current source term: x^{current_index} with coefficient {current_value}", size=13, fill="#486581"),
_svg_text(34, top_y - 18, "Raw convolution tail", size=14, weight="700"),
_svg_text(34, cyclic_y - 18, "Cyclic fold into x^n - 1", size=14, weight="700", fill=SVG_BLUE),
_svg_text(34, neg_y - 18, "Negacyclic fold into x^n + 1", size=14, weight="700", fill=SVG_ACCENT),
]
for index, coefficient in enumerate(coefficients):
@ -493,7 +542,7 @@ def _direct_ntt_frame_svg(values: Sequence[int], modulus: int, psi: int, output_
final = sum(product for _, _, product in contributions) % modulus
parts = [
f'<svg viewBox="0 0 {width} {height}" preserveAspectRatio="xMidYMin meet" width="100%" style="display:block;width:100%;max-width:100%;height:auto;background:{SVG_BG}; border-radius:0 0 14px 14px;">',
_svg_canvas_open(width, height),
f'<rect x="18" y="18" width="{width - 36}" height="{height - 36}" rx="18" fill="{SVG_PANEL}" stroke="#e8dcc9" stroke-width="2"></rect>',
_svg_text(34, 48, "Direct NTTψ Contribution Player", size=22, weight="800"),
_svg_text(34, 68, f"Building output slot j={output_index}, currently consuming input i={input_index}", size=13, fill="#486581"),
@ -537,8 +586,27 @@ def _direct_ntt_frame_svg(values: Sequence[int], modulus: int, psi: int, output_
parts.append(_svg_box(520 + index * 90, boxes_y, 74, 54, f"a{index}·w", str(product), fill=fill, stroke=stroke))
parts.append(_svg_text(556 + index * 90, boxes_y - 10, f"factor {factor}", size=11, anchor="middle"))
parts.append(_svg_text(34, height - 60, f"Current partial sum for j={output_index}: {partial} mod {modulus}", size=15, weight="700"))
parts.append(_svg_text(34, height - 34, f"Completed output slot y{output_index}: {final} mod {modulus}", size=15, weight="700", fill=SVG_ACCENT))
parts.append(
_svg_multiline_text(
34,
height - 68,
f"Current partial sum for j={output_index}: {partial} mod {modulus}",
max_chars=52,
size=15,
weight="700",
)
)
parts.append(
_svg_multiline_text(
34,
height - 36,
f"Completed output slot y{output_index}: {final} mod {modulus}",
max_chars=52,
size=15,
weight="700",
fill=SVG_ACCENT,
)
)
parts.append("</svg>")
return "".join(parts)
@ -575,7 +643,7 @@ def _butterfly_story_frame_svg(trace: TransformTrace, stage_index: int, pair_ind
spacing = 92
start_x = 60
parts = [
f'<svg viewBox="0 0 {width} {height}" preserveAspectRatio="xMidYMin meet" width="100%" style="display:block;width:100%;max-width:100%;height:auto;background:{SVG_BG}; border-radius:0 0 14px 14px;">',
_svg_canvas_open(width, height),
f'<rect x="18" y="18" width="{width - 36}" height="{height - 36}" rx="18" fill="{SVG_PANEL}" stroke="#e8dcc9" stroke-width="2"></rect>',
_svg_text(34, 48, f"{trace.algorithm.upper()} Butterfly Player", size=22, weight="800"),
_svg_text(34, 68, f"Stage {stage.stage_index}, active pair ({left}, {right}), zeta={zeta}", size=13, fill="#486581"),
@ -603,8 +671,29 @@ def _butterfly_story_frame_svg(trace: TransformTrace, stage_index: int, pair_ind
x_right = start_x + right * spacing + 32
parts.append(f'<line x1="{x_left}" y1="{input_y + 62}" x2="{x_right}" y2="{input_y + 62}" stroke="{SVG_ACCENT}" stroke-width="4"></line>')
parts.append(_svg_text((x_left + x_right) / 2, 196, f"pair ({left}, {right})", size=12, weight="700", fill=SVG_ACCENT, anchor="middle"))
parts.append(_svg_text((x_left + x_right) / 2, 214, f"inputs -> outputs = ({stage.input_values[left]}, {stage.input_values[right]}) -> ({stage.output_values[left]}, {stage.output_values[right]})", size=11, anchor="middle"))
parts.append(_svg_text((x_left + x_right) / 2, 232, stage.note, size=11, anchor="middle", fill="#486581"))
parts.append(
_svg_multiline_text(
(x_left + x_right) / 2,
214,
f"inputs -> outputs = ({stage.input_values[left]}, {stage.input_values[right]}) -> ({stage.output_values[left]}, {stage.output_values[right]})",
max_chars=34,
line_height=14,
size=11,
anchor="middle",
)
)
parts.append(
_svg_multiline_text(
(x_left + x_right) / 2,
242,
stage.note,
max_chars=36,
line_height=14,
size=11,
anchor="middle",
fill="#486581",
)
)
parts.append("</svg>")
return "".join(parts)

View file

@ -1,10 +1,33 @@
from __future__ import annotations
import os
import unittest
import ipywidgets as widgets
from ntt_learning.visuals import _convolution_frame_html, schoolbook_diagonal_player
from ntt_learning.course import REPO_ROOT
MPLCONFIGDIR = REPO_ROOT / ".cache" / "matplotlib"
MPLCONFIGDIR.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("MPLCONFIGDIR", str(MPLCONFIGDIR))
from ntt_learning.toy_ntt import fast_ntt_psi_ct_trace, find_psi
from ntt_learning.visuals import (
_convolution_frame_html,
butterfly_story_player,
direct_ntt_player,
schoolbook_diagonal_player,
wraparound_comparison_player,
)
def player_parts(player: widgets.Widget) -> tuple[widgets.IntSlider, widgets.HTML, widgets.HTML]:
content = player.children[1]
controls = content.children[0]
slider = controls.children[1]
frame_html = content.children[1]
caption_html = content.children[2]
return slider, frame_html, caption_html
class VisualUxTests(unittest.TestCase):
@ -30,11 +53,7 @@ class VisualUxTests(unittest.TestCase):
self.assertIsInstance(player, widgets.VBox)
self.assertEqual(player.layout.width, "100%")
content = player.children[1]
controls = content.children[0]
slider = controls.children[1]
frame_html = content.children[1]
caption_html = content.children[2]
slider, frame_html, caption_html = player_parts(player)
self.assertIsInstance(slider, widgets.IntSlider)
self.assertEqual(slider.layout.width, "100%")
@ -45,6 +64,40 @@ class VisualUxTests(unittest.TestCase):
self.assertIn("Active diagonal: y3", frame_html.value)
self.assertIn("Frame 4 of", caption_html.value)
def test_svg_players_render_in_scrollable_non_shrinking_frames(self) -> None:
psi = find_psi(order=4, modulus=17)
trace = fast_ntt_psi_ct_trace([1, 2, 3, 4], modulus=17, psi=psi)
players = [
wraparound_comparison_player([3, 0, 2, 1, 5, 4, 6], n=4),
direct_ntt_player([1, 2, 3, 4], modulus=17, psi=psi),
butterfly_story_player(trace),
]
for player in players:
slider, frame_html, _ = player_parts(player)
self.assertIsInstance(slider, widgets.IntSlider)
self.assertIn("overflow-x:auto", frame_html.value)
self.assertIn("<svg", frame_html.value)
self.assertIn("max-width:none", frame_html.value)
self.assertIn("min-width:", frame_html.value)
self.assertNotIn('width="100%"', frame_html.value)
def test_svg_players_advance_cleanly_when_slider_moves(self) -> None:
psi = find_psi(order=4, modulus=17)
trace = fast_ntt_psi_ct_trace([1, 2, 3, 4], modulus=17, psi=psi)
players = [
wraparound_comparison_player([3, 0, 2, 1, 5, 4, 6], n=4),
direct_ntt_player([1, 2, 3, 4], modulus=17, psi=psi),
butterfly_story_player(trace),
]
for player in players:
slider, frame_html, caption_html = player_parts(player)
before = frame_html.value
slider.value = 1
self.assertNotEqual(before, frame_html.value)
self.assertIn("Frame 2 of", caption_html.value)
if __name__ == "__main__":
unittest.main()