Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets

Add SVG-native animation with playback controls, various minor changes for 2.0

+53 -8
README.md
···
# Draw multiple circular arcs
d.append(draw.ArcLine(60, 20, 20, 60, 270,
stroke='red', stroke_width=5, fill='red', fill_opacity=0.2))
-
d.append(draw.Arc(60, 20, 20, 60, 270, cw=False,
stroke='green', stroke_width=3, fill='none'))
-
d.append(draw.Arc(60, 20, 20, 270, 60, cw=True,
stroke='blue', stroke_width=1, fill='black', fill_opacity=0.3))
# Draw arrows
···
[![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example1.png)](https://github.com/cduck/drawsvg/blob/master/examples/example1.svg)
### Gradients
```python
import drawsvg as draw
···
# Draw a shape to fill with the gradient
p = draw.Path(fill=gradient, stroke='black', stroke_width=0.002)
-
p.arc(0, 0.35, 0.7, 30, 120)
-
p.arc(0, 0.35, 0.5, 120, 30, cw=True, include_l=True)
p.Z()
d.append(p)
# Draw another shape to fill with the same gradient
p = draw.Path(fill=gradient, stroke='red', stroke_width=0.002)
-
p.arc(0, 0.35, 0.75, 130, 160)
-
p.arc(0, 0.35, 0, 160, 130, cw=True, include_l=True)
p.Z()
d.append(p)
···
Note: The above example currently only works in `jupyter notebook`, not `jupyter lab`.
-
### Animation with Python
```python
import drawsvg as draw
···
![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example6.gif)
-
### Asynchronous Animation in Jupyter
```python
# Jupyter cell 1:
import drawsvg as draw
···
# Draw multiple circular arcs
d.append(draw.ArcLine(60, 20, 20, 60, 270,
stroke='red', stroke_width=5, fill='red', fill_opacity=0.2))
+
d.append(draw.Arc(60, 20, 20, 90, -60, cw=True,
stroke='green', stroke_width=3, fill='none'))
+
d.append(draw.Arc(60, 20, 20, -60, 90, cw=False,
stroke='blue', stroke_width=1, fill='black', fill_opacity=0.3))
# Draw arrows
···
[![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example1.png)](https://github.com/cduck/drawsvg/blob/master/examples/example1.svg)
+
### SVG-Native Animation with playback controls
+
```python
+
import drawsvg as draw
+
+
d = draw.Drawing(400, 200, origin='center',
+
animation_config=draw.types.SyncedAnimationConfig(
+
# Animation configuration
+
duration=8, # Seconds
+
show_playback_progress=True,
+
show_playback_controls=True,
+
)
+
)
+
d.append(draw.Rectangle(-200, -100, 400, 200, fill='#eee')) # Background
+
d.append(draw.Circle(0, 0, 40, fill='green')) # Center circle
+
circle = draw.Circle(0, 0, 0, fill='silver', stroke='gray') # Moving circle
+
# Animation
+
circle.add_key_frame(0, cx=-100, cy=0, r=0, stroke_width=0)
+
circle.add_key_frame(2, cx=0, cy=-100, r=40, stroke_width=5)
+
circle.add_key_frame(4, cx=100, cy=0, r=0, stroke_width=0)
+
circle.add_key_frame(6, cx=0, cy=100, r=40, stroke_width=5)
+
circle.add_key_frame(8, cx=-100, cy=0, r=0, stroke_width=0)
+
d.append(circle)
+
+
# Changing text
+
draw.native_animation.animate_text_sequence(
+
d,
+
[0, 2, 4, 6],
+
['0', '1', '2', '3'],
+
30, 0, 1, fill='yellow', center=True)
+
+
# Save as a standalone animated SVG or HTML
+
d.save_svg('examples/playback-controls.svg')
+
d.save_html('examples/playback-controls.html')
+
+
# Display in Jupyter notebook
+
#d.display_image() # Display SVG as an image (will not be interactive)
+
#d.display_iframe() # Display as interactive SVG (alternative)
+
d.display_inline() # Display as interactive SVG
+
```
+
+
[![Example animated image](https://github.com/cduck/drawsvg/blob/v2/examples/playback-controls.svg)](https://raw.githubusercontent.com/cduck/drawsvg/v2/examples/playback-controls.svg)
+
+
Note: GitHub blocks the playback controls.
+
Download the above SVG and open it in a web browser to try.
+
### Gradients
```python
import drawsvg as draw
···
# Draw a shape to fill with the gradient
p = draw.Path(fill=gradient, stroke='black', stroke_width=0.002)
+
p.arc(0, 0.35, 0.7, -30, -120, cw=False)
+
p.arc(0, 0.35, 0.5, -120, -30, cw=True, include_l=True)
p.Z()
d.append(p)
# Draw another shape to fill with the same gradient
p = draw.Path(fill=gradient, stroke='red', stroke_width=0.002)
+
p.arc(0, 0.35, 0.75, -130, -160, cw=False)
+
p.arc(0, 0.35, 0, -160, -130, cw=True, include_l=True)
p.Z()
d.append(p)
···
Note: The above example currently only works in `jupyter notebook`, not `jupyter lab`.
+
### Frame-by-Frame Animation
```python
import drawsvg as draw
···
![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example6.gif)
+
### Asynchronous Frame-based Animation in Jupyter
```python
# Jupyter cell 1:
import drawsvg as draw
+10
drawsvg/__init__.py
···
frame_animate_video,
frame_animate_jupyter,
)
···
frame_animate_video,
frame_animate_jupyter,
)
+
from .native_animation import (
+
SyncedAnimationConfig,
+
animate_element_sequence,
+
animate_text_sequence,
+
)
+
from .url_encode import (
+
bytes_as_data_uri,
+
svg_as_data_uri,
+
svg_as_utf8_data_uri,
+
)
+76 -26
drawsvg/drawing.py
···
from io import StringIO
from collections import defaultdict
import random
import string
from . import Raster
from . import types, elements as elements_module, jupyter
-
-
SVG_START = '''<?xml version="1.0" encoding="UTF-8"?>
-
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
-
'''
SVG_END = '</svg>'
-
SVG_CSS_FMT = '<style><![CDATA[{}]]></style>'
-
SVG_JS_FMT = '<script><![CDATA[{}]]></script>'
class Drawing:
···
displayed as an SVG below.
'''
def __init__(self, width, height, origin=(0,0), context: types.Context=None,
-
id_prefix='d', **svg_args):
-
assert float(width) == width
-
assert float(height) == height
if context is None:
context = types.Context()
self.width = width
self.height = height
if isinstance(origin, str):
self.view_box = {
'center': (-width/2, -height/2, width, height),
-
'top-left': (0, 0, width, height),
-
'top-right': (-width, 0, width, height),
-
'bottom-left': (0, -height, width, height),
-
'bottom-right': (-width, -height, width, height),
}[origin]
else:
origin = tuple(origin)
-
assert len(origin) == 2
self.view_box = origin + (width, height)
self.elements = []
self.ordered_elements = defaultdict(list)
···
if not hasattr(obj, 'write_svg_element'):
elements = obj.to_drawables(**kwargs)
else:
-
assert len(kwargs) == 0
elements = obj
if hasattr(elements, 'write_svg_element'):
self.append(elements, z=z)
···
if not hasattr(obj, 'write_svg_element'):
elements = obj.to_drawables(**kwargs)
else:
-
assert len(kwargs) == 0
elements = obj
if hasattr(elements, 'write_svg_element'):
self.append_def(elements)
···
self.append(elements_module.Title(text, **kwargs))
def append_css(self, css_text):
self.css_list.append(css_text)
-
def append_javascriipt(self, js_text, onload=None):
if onload:
if self.svg_args.get('onload'):
self.svg_args['onload'] = f'{self.svg_args["onload"]};{onload}'
···
for z in sorted(self.ordered_elements):
output.extend(self.ordered_elements[z])
return output
-
def as_svg(self, output_file=None, randomize_ids=False):
if output_file is None:
with StringIO() as f:
-
self.as_svg(f, randomize_ids=randomize_ids)
return f.getvalue()
img_width, img_height = self.calc_render_size()
svg_args = dict(
width=img_width, height=img_height,
···
output_file.write(SVG_START)
self.context.write_svg_document_args(svg_args, output_file)
output_file.write('>\n')
-
if self.css_list:
-
output_file.write(SVG_CSS_FMT.format('\n'.join(self.css_list)))
-
output_file.write('\n')
-
if self.js_list:
-
output_file.write(SVG_JS_FMT.format('\n'.join(self.js_list)))
output_file.write('\n')
output_file.write('<defs>\n')
# Write definition elements
···
return id_str
id_map = defaultdict(id_gen)
prev_set = set((id(defn) for defn in self.other_defs))
def is_duplicate(obj):
nonlocal prev_set
dup = id(obj) in prev_set
prev_set.add(id(obj))
return dup
for element in self.other_defs:
if hasattr(element, 'write_svg_element'):
···
element.write_svg_element(
id_map, is_duplicate, output_file, self.context, False)
output_file.write('\n')
output_file.write(SVG_END)
@staticmethod
def _random_id(length=8):
return (random.choice(string.ascii_letters)
···
def save_svg(self, fname, encoding='utf-8'):
with open(fname, 'w', encoding=encoding) as f:
self.as_svg(output_file=f)
def save_png(self, fname):
self.rasterize(to_file=fname)
def rasterize(self, to_file=None):
-
if to_file:
return Raster.from_svg_to_file(self.as_svg(), to_file)
else:
return Raster.from_svg(self.as_svg())
···
def display_iframe(self):
'''Display within an iframe the Jupyter web page.'''
w, h = self.calc_render_size()
-
return jupyter.JupyterSvgFrame(self.as_svg(), w, h)
def display_image(self):
'''Display within an img in the Jupyter web page.'''
return jupyter.JupyterSvgImage(self.as_svg())
···
+
import dataclasses
from io import StringIO
from collections import defaultdict
import random
import string
+
import xml.sax.saxutils as xml
from . import Raster
from . import types, elements as elements_module, jupyter
+
XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n'
+
SVG_START = ('<svg xmlns="http://www.w3.org/2000/svg" '
+
'xmlns:xlink="http://www.w3.org/1999/xlink"\n ')
SVG_END = '</svg>'
+
SVG_CSS_FMT = '<style>/*<![CDATA[*/{}/*]]>*/</style>'
+
SVG_JS_FMT = '<script>/*<![CDATA[*/{}/*]]>*/</script>'
class Drawing:
···
displayed as an SVG below.
'''
def __init__(self, width, height, origin=(0,0), context: types.Context=None,
+
animation_config=None, id_prefix='d', **svg_args):
if context is None:
context = types.Context()
+
if animation_config is not None:
+
context = dataclasses.replace(
+
context, animation_config=animation_config)
self.width = width
self.height = height
if isinstance(origin, str):
+
top, bottom = 0, -height
+
if context.invert_y:
+
top, bottom = bottom, top
self.view_box = {
'center': (-width/2, -height/2, width, height),
+
'top-left': (0, top, width, height),
+
'top-right': (-width, top, width, height),
+
'bottom-left': (0, bottom, width, height),
+
'bottom-right': (-width, bottom, width, height),
}[origin]
else:
origin = tuple(origin)
+
if len(origin) != 2:
+
raise ValueError(
+
"origin must be the string 'center', 'top-left', ..., "
+
"'bottom-right' or a tuple (x, y)")
self.view_box = origin + (width, height)
self.elements = []
self.ordered_elements = defaultdict(list)
···
if not hasattr(obj, 'write_svg_element'):
elements = obj.to_drawables(**kwargs)
else:
+
if len(kwargs) > 0:
+
raise ValueError('unexpected kwargs')
elements = obj
if hasattr(elements, 'write_svg_element'):
self.append(elements, z=z)
···
if not hasattr(obj, 'write_svg_element'):
elements = obj.to_drawables(**kwargs)
else:
+
if len(kwargs) > 0:
+
raise ValueError('unexpected kwargs')
elements = obj
if hasattr(elements, 'write_svg_element'):
self.append_def(elements)
···
self.append(elements_module.Title(text, **kwargs))
def append_css(self, css_text):
self.css_list.append(css_text)
+
def append_javascript(self, js_text, onload=None):
if onload:
if self.svg_args.get('onload'):
self.svg_args['onload'] = f'{self.svg_args["onload"]};{onload}'
···
for z in sorted(self.ordered_elements):
output.extend(self.ordered_elements[z])
return output
+
def as_svg(self, output_file=None, randomize_ids=False, header=XML_HEADER,
+
skip_js=False, skip_css=False):
if output_file is None:
with StringIO() as f:
+
self.as_svg(f, randomize_ids=randomize_ids, header=header)
return f.getvalue()
+
output_file.write(header)
img_width, img_height = self.calc_render_size()
svg_args = dict(
width=img_width, height=img_height,
···
output_file.write(SVG_START)
self.context.write_svg_document_args(svg_args, output_file)
output_file.write('>\n')
+
if self.css_list and not skip_css:
+
output_file.write(SVG_CSS_FMT.format(elements_module.escape_cdata(
+
'\n'.join(self.css_list))))
output_file.write('\n')
output_file.write('<defs>\n')
# Write definition elements
···
return id_str
id_map = defaultdict(id_gen)
prev_set = set((id(defn) for defn in self.other_defs))
+
prev_list = []
def is_duplicate(obj):
nonlocal prev_set
dup = id(obj) in prev_set
prev_set.add(id(obj))
+
prev_list.append(obj)
return dup
for element in self.other_defs:
if hasattr(element, 'write_svg_element'):
···
element.write_svg_element(
id_map, is_duplicate, output_file, self.context, False)
output_file.write('\n')
+
if self.js_list and not skip_js:
+
output_file.write(SVG_JS_FMT.format(elements_module.escape_cdata(
+
'\n'.join(self.js_list))))
+
output_file.write('\n')
output_file.write(SVG_END)
+
def as_html(self, output_file=None, title=None, randomize_ids=False,
+
fix_embed_iframe=False):
+
if output_file is None:
+
with StringIO() as f:
+
self.as_html(
+
f, title=title, randomize_ids=randomize_ids,
+
fix_embed_iframe=fix_embed_iframe)
+
return f.getvalue()
+
output_file.write('<!DOCTYPE html>\n')
+
output_file.write('<head>\n')
+
output_file.write('<meta charset="utf-8">\n')
+
if title is not None:
+
output_file.write(f'<title>{xml.escape(title)}</title>\n')
+
# Prevent iframe scroll bar
+
if fix_embed_iframe:
+
fix = self.calc_render_size()[1] / 2
+
output_file.write(f'''<style>
+
html,body {{
+
margin: 0;
+
height: 100%;
+
}}
+
svg {{
+
margin-bottom: {-fix}px;
+
}}
+
</style>''')
+
output_file.write('</head>\n<body>\n')
+
self.as_svg(
+
output_file, randomize_ids=randomize_ids, header="",
+
skip_css=False, skip_js=False)
+
output_file.write('\n</body>\n</html>\n')
@staticmethod
def _random_id(length=8):
return (random.choice(string.ascii_letters)
···
def save_svg(self, fname, encoding='utf-8'):
with open(fname, 'w', encoding=encoding) as f:
self.as_svg(output_file=f)
+
def save_html(self, fname, title=None, encoding='utf-8'):
+
with open(fname, 'w', encoding=encoding) as f:
+
self.as_html(output_file=f, title=title)
def save_png(self, fname):
self.rasterize(to_file=fname)
def rasterize(self, to_file=None):
+
if to_file is not None:
return Raster.from_svg_to_file(self.as_svg(), to_file)
else:
return Raster.from_svg(self.as_svg())
···
def display_iframe(self):
'''Display within an iframe the Jupyter web page.'''
w, h = self.calc_render_size()
+
html = self.as_html(fix_embed_iframe=True)
+
return jupyter.JupyterSvgFrame(html, w, h, mime='text/html')
def display_image(self):
'''Display within an img in the Jupyter web page.'''
return jupyter.JupyterSvgImage(self.as_svg())
+20 -11
drawsvg/elements.py
···
from .types import DrawingElement, DrawingBasicElement, DrawingParentElement
class NoElement(DrawingElement):
''' A drawing element that has no effect '''
def __init__(self):
···
'''
TAG_NAME = 'g'
-
class Raw(DrawingBasicElement):
'''Raw unescaped text to include in the SVG output.
Special XML characters like '<' and '&' in the content may have unexpected
···
super().__init__()
self.content = content
self.defs = defs
-
def write_content(self, id_map, is_duplicate, output_file, context,
-
dry_run):
if dry_run:
return
output_file.write(self.content)
···
from_ = from_or_values
if isinstance(other_elem, str) and not other_elem.startswith('#'):
other_elem = '#' + other_elem
-
kwargs.update(attributeName=attributeName, to=to, dur=dur, begin=begin)
-
kwargs.setdefault('values', values)
-
kwargs.setdefault('from_', from_)
-
super().__init__(xlink__href=other_elem, **kwargs)
def get_svg_defs(self):
return [v for k, v in self.args.items()
···
Useful SVG attributes:
- text_anchor: start, middle, end
-
- dominant_baseline: auto, central, middle, hanging, text-top, ...
See https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text
CairoSVG bug with letter spacing text on a path: The first two letters are
···
assert sum(bool(line) for line in text) <= 1, (
'Logic error, __new__ should handle multi-line paths')
for i, line in enumerate(text):
-
if not line:
continue
dy = '{}em'.format(line_offset + i*line_height)
tspan = TSpan(line, dy=dy, **tspan_args)
···
output_file.write(self.escaped_text)
def write_children_content(self, id_map, is_duplicate, output_file, context,
dry_run):
-
children = self.all_children()
for child in children:
child.write_svg_element(
id_map, is_duplicate, output_file, context, dry_run)
···
def __init__(self, sx, sy, *points, close=False, **kwargs):
super().__init__(d='', **kwargs)
self.M(sx, sy)
-
assert len(points) % 2 == 0
for i in range(len(points) // 2):
self.L(points[2*i], points[2*i+1])
if close:
···
from .types import DrawingElement, DrawingBasicElement, DrawingParentElement
+
def escape_cdata(content):
+
return content.replace(']]>', ']]]]><![CDATA[>')
+
+
class NoElement(DrawingElement):
''' A drawing element that has no effect '''
def __init__(self):
···
'''
TAG_NAME = 'g'
+
class Raw(DrawingElement):
'''Raw unescaped text to include in the SVG output.
Special XML characters like '<' and '&' in the content may have unexpected
···
super().__init__()
self.content = content
self.defs = defs
+
def write_svg_element(self, id_map, is_duplicate, output_file, context,
+
dry_run, force_dup=False):
if dry_run:
return
output_file.write(self.content)
···
from_ = from_or_values
if isinstance(other_elem, str) and not other_elem.startswith('#'):
other_elem = '#' + other_elem
+
args = dict(
+
attributeName=attributeName, dur=dur, begin=begin, from_=from_,
+
to=to, values=values)
+
args.update(kwargs)
+
super().__init__(xlink__href=other_elem, **args)
def get_svg_defs(self):
return [v for k, v in self.args.items()
···
Useful SVG attributes:
- text_anchor: start, middle, end
+
- dominant_baseline:
+
auto, central, middle, hanging, text-top, mathematical, ...
See https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text
CairoSVG bug with letter spacing text on a path: The first two letters are
···
assert sum(bool(line) for line in text) <= 1, (
'Logic error, __new__ should handle multi-line paths')
for i, line in enumerate(text):
+
if line is None or len(line) == 0:
continue
dy = '{}em'.format(line_offset + i*line_height)
tspan = TSpan(line, dy=dy, **tspan_args)
···
output_file.write(self.escaped_text)
def write_children_content(self, id_map, is_duplicate, output_file, context,
dry_run):
+
children = self.all_children(context=context)
for child in children:
child.write_svg_element(
id_map, is_duplicate, output_file, context, dry_run)
···
def __init__(self, sx, sy, *points, close=False, **kwargs):
super().__init__(d='', **kwargs)
self.M(sx, sy)
+
if len(points) % 2 != 0:
+
raise TypeError(
+
'expected an even number of positional arguments x0, y0, '
+
'x1, y1, ...')
for i in range(len(points) // 2):
self.L(points[2*i], points[2*i+1])
if close:
+2 -1
drawsvg/jupyter.py
···
svg: str
width: float
height: float
def _repr_html_(self):
-
uri = url_encode.svg_as_utf8_data_uri(self.svg)
return (f'<iframe src="{uri}" width="{self.width}" '
f'height="{self.height}" style="border:0" />')
···
svg: str
width: float
height: float
+
mime: str = 'image/svg+xml'
def _repr_html_(self):
+
uri = url_encode.svg_as_data_uri(self.svg, mime=self.mime)
return (f'<iframe src="{uri}" width="{self.width}" '
f'height="{self.height}" style="border:0" />')
+8
drawsvg/native_animation/__init__.py
···
···
+
from .synced_animation import (
+
SyncedAnimationConfig,
+
AnimatedAttributeTimeline,
+
AnimationHelperData,
+
animate_element_sequence,
+
animate_text_sequence,
+
)
+
from .playback_control_ui import draw_scrub
+120
drawsvg/native_animation/playback_control_js.py
···
···
+
SVG_ONLOAD = 'svgOnLoad(event)'
+
SVG_JS_CONTENT = '''
+
/* Animation playback controls generated by drawsvg */
+
/* https://github.com/cduck/drawsvg/ */
+
function svgOnLoad(event) {
+
/* Support standalone SVG or embedded in HTML or iframe */
+
if (event && event.target && event.target.ownerDocument) {
+
svgSetup(event.target.ownerDocument);
+
} else if (document && document.currentScript
+
&& document.currentScript.parentElement) {
+
svgSetup(document.currentScript.parentElement);
+
}
+
}
+
function svgSetup(doc) {
+
var svgRoot = doc.documentElement || doc;
+
var scrubCapture = doc.getElementById("scrub-capture");
+
/* Block multiple setups */
+
if (!scrubCapture || scrubCapture.getAttribute("svgSetupDone")) {
+
return;
+
}
+
scrubCapture.setAttribute("svgSetupDone", true);
+
var scrubContainer = doc.getElementById("scrub");
+
var scrubPlay = doc.getElementById("scrub-play");
+
var scrubPause = doc.getElementById("scrub-pause");
+
var scrubKnob = doc.getElementById("scrub-knob");
+
var scrubXMin = parseFloat(scrubCapture.dataset.xmin);
+
var scrubXMax = parseFloat(scrubCapture.dataset.xmax);
+
var scrubTotalDur = parseFloat(scrubCapture.dataset.totaldur);
+
var scrubStartDelay = parseFloat(scrubCapture.dataset.startdelay);
+
var scrubEndDelay = parseFloat(scrubCapture.dataset.enddelay);
+
var scrubPauseOnLoad = parseFloat(scrubCapture.dataset.pauseonload);
+
var paused = false;
+
var dragXOffset = 0;
+
var point = svgRoot.createSVGPoint();
+
+
function screenToSvgX(p) {
+
var matrix = scrubKnob.getScreenCTM().inverse();
+
point.x = p.x;
+
point.y = p.y;
+
return point.matrixTransform(matrix).x;
+
};
+
function screenToProgress(p) {
+
var matrix = scrubKnob.getScreenCTM().inverse();
+
point.x = p.x;
+
point.y = p.y;
+
var x = point.matrixTransform(matrix).x;
+
if (x <= scrubXMin) {
+
return scrubStartDelay / scrubTotalDur;
+
}
+
if (x >= scrubXMax) {
+
return (scrubTotalDur - scrubEndDelay) / scrubTotalDur;
+
}
+
return (scrubStartDelay/scrubTotalDur
+
+ (x - dragXOffset - scrubXMin)
+
/ (scrubXMax - scrubXMin)
+
* (scrubTotalDur - scrubStartDelay - scrubEndDelay)
+
/ scrubTotalDur);
+
};
+
function currentScrubX() {
+
return scrubKnob.cx.animVal.value;
+
};
+
function pause() {
+
svgRoot.pauseAnimations();
+
scrubPlay.setAttribute("visibility", "visible");
+
scrubPause.setAttribute("visibility", "hidden");
+
paused = true;
+
};
+
function play() {
+
svgRoot.unpauseAnimations();
+
scrubPause.setAttribute("visibility", "visible");
+
scrubPlay.setAttribute("visibility", "hidden");
+
paused = false;
+
};
+
function scrub(playbackFraction) {
+
var t = scrubTotalDur * playbackFraction;
+
/* Stop 10ms before end to avoid loop (>=1ms needed on FF) */
+
var limit = scrubTotalDur - 10e-3;
+
if (t < 0) t = 0;
+
else if (t > limit) t = limit;
+
svgRoot.setCurrentTime(t);
+
};
+
function mousedown(e) {
+
svgRoot.pauseAnimations();
+
if (e.target == scrubKnob) {
+
dragXOffset = screenToSvgX(e) - currentScrubX();
+
} else {
+
dragXOffset = 0;
+
}
+
scrub(screenToProgress(e));
+
/* Global document listeners */
+
document.addEventListener('mousemove', mousemove);
+
document.addEventListener('mouseup', mouseup);
+
e.preventDefault();
+
};
+
function mouseup(e) {
+
dragXOffset = 0;
+
document.removeEventListener('mousemove', mousemove);
+
document.removeEventListener('mouseup', mouseup);
+
if (!paused) {
+
svgRoot.unpauseAnimations();
+
}
+
e.preventDefault();
+
};
+
function mousemove(e) {
+
scrub(screenToProgress(e));
+
};
+
scrubPause.addEventListener("click", pause);
+
scrubPlay.addEventListener("click", play);
+
scrubCapture.addEventListener("mousedown", mousedown);
+
scrubContainer.setAttribute("visibility", "visible");
+
scrubKnob.setAttribute("visibility", "visible");
+
if (scrubPauseOnLoad) {
+
pause();
+
scrub(0);
+
} else {
+
play();
+
}
+
};
+
svgOnLoad();
+
'''
+87
drawsvg/native_animation/playback_control_ui.py
···
···
+
from .. import elements as draw
+
+
+
def draw_scrub(config: 'SyncedAnimationConfig', hidden=False) -> 'Group':
+
hpad = config.bar_hpad
+
bar_x = config.controls_x + hpad
+
bar_y = config.controls_center_y
+
bar_width = config.controls_width - 2*hpad
+
knob_rad = config.knob_rad
+
pause_width = config.pause_width
+
pause_corner_rad = config.pause_corner_rad
+
g = draw.Group(id='scrub', visibility='hidden' if hidden else None)
+
g.append(draw.Line(
+
bar_x, bar_y, bar_x+bar_width, bar_y,
+
stroke=config.bar_color,
+
stroke_width=config.bar_thickness,
+
stroke_linecap='round'))
+
progress = draw.Rectangle(
+
bar_x, bar_y, 0, 0.001,
+
stroke=config.bar_past_color,
+
stroke_width=config.bar_thickness,
+
stroke_linejoin='round')
+
g.append(progress)
+
g_capture = draw.Group(
+
id='scrub-capture',
+
data_xmin=bar_x,
+
data_xmax=bar_x+bar_width,
+
data_totaldur=config.total_duration,
+
data_startdelay=config.start_delay,
+
data_enddelay=config.end_delay,
+
data_pauseonload=int(bool(config.pause_on_load)))
+
g_capture.append(draw.Rectangle(
+
bar_x-config.bar_thickness/2, bar_y-config.controls_height/2,
+
bar_width+config.bar_thickness, config.controls_height,
+
fill='rgba(255,255,255,0)'))
+
knob = draw.Circle(
+
bar_x, bar_y, knob_rad, fill=config.knob_fill,
+
id='scrub-knob',
+
visibility='hidden')
+
g_capture.append(knob)
+
g.append(g_capture)
+
g_play = draw.Group(id='scrub-play', visibility='hidden')
+
g_play.append(draw.Rectangle(
+
bar_x - hpad/2 - knob_rad/2 - pause_width/2 + pause_corner_rad,
+
bar_y - pause_width/2 + pause_corner_rad,
+
pause_width - pause_corner_rad*2,
+
pause_width - pause_corner_rad*2,
+
fill=config.pause_color,
+
stroke=config.pause_color,
+
stroke_width=pause_corner_rad*2,
+
stroke_linejoin='round'))
+
g_play.append(draw.Path(fill=config.pause_icon_color)
+
.M(bar_x - hpad/2 - knob_rad/2 - pause_width/4,
+
bar_y - pause_width/4)
+
.v(pause_width/2)
+
.l(pause_width/4*2, -pause_width/4)
+
.Z())
+
g.append(g_play)
+
g_pause = draw.Group(id='scrub-pause', visibility='hidden')
+
g_pause.append(draw.Rectangle(
+
bar_x - hpad/2 - knob_rad/2 - pause_width/2 + pause_corner_rad,
+
bar_y - pause_width/2 + pause_corner_rad,
+
pause_width - pause_corner_rad*2,
+
pause_width - pause_corner_rad*2,
+
fill=config.pause_color,
+
stroke=config.pause_color,
+
stroke_width=pause_corner_rad*2,
+
stroke_linejoin='round'))
+
g_pause.append(draw.Rectangle(
+
bar_x - hpad/2 - knob_rad/2 - pause_width/16*3,
+
bar_y - pause_width/4,
+
pause_width/8,
+
pause_width/2,
+
fill=config.pause_icon_color))
+
g_pause.append(draw.Rectangle(
+
bar_x - hpad/2 - knob_rad/2 + pause_width/16,
+
bar_y - pause_width/4,
+
pause_width/8,
+
pause_width/2,
+
fill=config.pause_icon_color))
+
g.append(g_pause)
+
+
progress.add_key_frame(0, width=0)
+
progress.add_key_frame(config.duration, width=bar_width)
+
knob.add_key_frame(0, cx=bar_x)
+
knob.add_key_frame(config.duration, cx=bar_x+bar_width)
+
return g
+210
drawsvg/native_animation/synced_animation.py
···
···
+
from typing import Any, Callable, Dict, List, Optional, Union
+
+
import dataclasses
+
from collections import defaultdict
+
+
from .. import elements, types
+
from . import playback_control_ui, playback_control_js
+
+
+
@dataclasses.dataclass
+
class SyncedAnimationConfig:
+
# Animation settings
+
duration: float
+
start_delay: float = 0
+
end_delay: float = 0
+
repeat_count: Union[int, str] = 'indefinite'
+
fill: str = 'freeze'
+
+
# Playback controls
+
show_playback_progress: bool = False
+
show_playback_controls: bool = False # Adds JavaScript to the drawing
+
pause_on_load: bool = False
+
controls_width: Optional[float] = None
+
controls_height: float = 20
+
controls_x: Optional[float] = None
+
controls_center_y: Optional[float] = None
+
+
# Playback control style
+
bar_thickness: float = 4
+
bar_hpad: float = 32
+
bar_color: str = '#ccc'
+
bar_past_color: str = '#05f'
+
knob_rad: float = 6
+
knob_fill: str = '#05f'
+
pause_width: float = 16
+
pause_corner_rad: float = 4
+
pause_color: str = '#05f'
+
pause_icon_color: str = '#eee'
+
+
# Advanced configuration
+
controls_js: str = 'DEFAULT'
+
controls_js_onload: str = playback_control_js.SVG_ONLOAD
+
controls_draw_function: Callable[['SyncedAnimationConfig', bool], 'Group'
+
] = playback_control_ui.draw_scrub
+
+
@property
+
def total_duration(self):
+
return self.start_delay + self.duration + self.end_delay
+
+
def drawing_creation_hook(self, d, context):
+
'''Called by Drawing on initialization.'''
+
config = self._with_filled_defaults(d, context)
+
if self.show_playback_progress or self.show_playback_controls:
+
# Append control UI
+
controls = config.controls_draw_function(
+
config, hidden=not self.show_playback_progress)
+
d.append(controls, z=float('inf'))
+
if self.show_playback_controls:
+
# Add control JavaScript
+
d.append_javascript(config.controls_js,
+
onload=config.controls_js_onload)
+
+
def _with_filled_defaults(self, d, context):
+
# By default place the controls along the bottom edge
+
width = d.view_box[2]
+
x = d.view_box[0]
+
if context.invert_y:
+
y = d.view_box[1] + self.controls_height/2
+
else:
+
y = d.view_box[1] + d.view_box[3] - self.controls_height/2
+
js = playback_control_js.SVG_JS_CONTENT
+
if self.controls_width is not None:
+
width = self.controls_width
+
x += (d.view_box[2] - width) / 2
+
if self.controls_x is not None:
+
x = self.controls_x
+
if self.controls_center_y is not None:
+
y = self.controls_center_y
+
if self.controls_js != 'DEFAULT':
+
js = self.controls_js
+
return dataclasses.replace(
+
self, controls_width=width, controls_x=x, controls_center_y=y,
+
controls_js=js)
+
+
+
@dataclasses.dataclass
+
class AnimatedAttributeTimeline:
+
name: str
+
animate_attrs: Optional[Dict[str, Any]] = None
+
times: List[float] = dataclasses.field(default_factory=list)
+
values: List[Any] = dataclasses.field(default_factory=list)
+
+
def __post_init__(self):
+
self.name = types.normalize_attribute_name(self.name)
+
+
def append(self, time, value):
+
if self.times and time < self.times[-1]:
+
raise ValueError('out-of-order key frame times')
+
self.times.append(time)
+
self.values.append(value)
+
+
def extend(self, times, values):
+
if len(times) != len(values):
+
raise ValueError('times and values lists are mismatched lengths')
+
if len(self.times) and len(times) and times[0] < self.times[-1]:
+
raise ValueError('out-of-order key frame times')
+
if list(times) != sorted(times):
+
raise ValueError('out-of-order key frame times')
+
self.times.extend(times)
+
self.values.extend(values)
+
+
def as_animate_element(self, config: Optional[SyncedAnimationConfig]=None):
+
if config is not None:
+
total_duration = (
+
config.start_delay + config.duration + config.end_delay)
+
start_delay = config.start_delay
+
repeat_count = config.repeat_count
+
fill = config.fill
+
else:
+
total_duration = self.times[-1]
+
start_delay = 0
+
repeat_count = 1
+
fill = 'freeze'
+
dur_str = f'{total_duration}s'
+
key_times = ';'.join(
+
str(max(0, min(1, round(
+
(start_delay + t) / total_duration, 3))))
+
for t in self.times
+
)
+
values_str = ';'.join(map(str, self.values))
+
if not key_times.startswith('0;'):
+
key_times = '0;' + key_times
+
values_str = f'{self.values[0]};' + values_str
+
if not key_times.endswith(';1'):
+
key_times = key_times + ';1'
+
values_str = values_str + f';{self.values[-1]}'
+
attrs = dict(
+
dur=dur_str,
+
values=values_str,
+
keyTimes=key_times,
+
repeatCount=repeat_count,
+
fill=fill)
+
attrs.update(self.animate_attrs or {})
+
anim = elements.Animate(self.name, **attrs)
+
return anim
+
+
+
class AnimationHelperData:
+
def __init__(self):
+
self.attr_timelines = {}
+
+
def add_key_frame(self, time, animation_args=None, **attr_values):
+
for attr, val in attr_values.items():
+
attr = types.normalize_attribute_name(attr)
+
timeline = self.attr_timelines.get(attr)
+
if timeline is None:
+
timeline = AnimatedAttributeTimeline(attr, animation_args)
+
self.attr_timelines[attr] = timeline
+
timeline.append(time, val)
+
+
def add_attribute_key_sequence(self, attr, times, values, *,
+
animation_args=None):
+
attr = types.normalize_attribute_name(attr)
+
timeline = self.attr_timelines.get(attr)
+
if timeline is None:
+
timeline = AnimatedAttributeTimeline(attr, animation_args)
+
self.attr_timelines[attr] = timeline
+
timeline.extend(times, values)
+
+
def children_with_context(self, context=None):
+
return [
+
timeline.as_animate_element(context.animation_config)
+
for timeline in self.attr_timelines.values()
+
]
+
+
+
def animate_element_sequence(times, element_sequence):
+
'''Animate a list of elements to appear one-at-a-time in sequence.
+
+
Elements should already be added to the drawing before using this.
+
'''
+
for i, elem in enumerate(element_sequence):
+
if elem is None:
+
continue # Draw nothing during this time
+
key_times = [times[i]]
+
values = ['visible']
+
if i > 0:
+
key_times.insert(0, times[i-1])
+
values.insert(0, 'hidden')
+
if i < len(element_sequence) - 1:
+
key_times.append(times[i+1])
+
values.append('hidden')
+
elem.add_attribute_key_sequence('visibility', key_times, values)
+
+
def animate_text_sequence(container, times: List[float], values: List[str],
+
*text_args, kwargs_list=None, **text_kwargs):
+
'''Animate a sequence of text to appear one-at-a-time in sequence.
+
+
Multiple `Text` elements will be appended to the given `container`.
+
'''
+
if kwargs_list is None:
+
kwargs_list = [None] * len(values)
+
new_elements = []
+
for val, current_kw in zip(values, kwargs_list):
+
kwargs = dict(text_kwargs)
+
if current_kw is not None:
+
kwargs.update(current_kw)
+
new_elements.append(elements.Text(val, *text_args, **kwargs))
+
animate_element_sequence(times, new_elements)
+
container.extend(new_elements)
+59 -24
drawsvg/types.py
···
from collections import defaultdict
import dataclasses
from . import elements
-
@dataclasses.dataclass
class Context:
'''Additional drawing configuration that can modify element's SVG output.'''
invert_y: bool = False
def drawing_creation_hook(self, d):
'''Called by Drawing on initialization.'''
-
...
def override_view_box(self, view_box):
if self.invert_y:
···
def override_args(self, args):
args = dict(args)
if self.invert_y:
-
if 'cy' in args:
-
# Flip y for circle and ellipse
-
try:
-
args['cy'] = -args['cy']
-
except TypeError:
-
pass
if 'y' in args:
# Flip y for most elements
try:
···
if v is None: continue
if isinstance(v, DrawingElement):
mapped_id = v.id
-
if id_map and id(v) in id_map:
mapped_id = id_map[id(v)]
if mapped_id is None:
continue
···
def __init__(self, **args):
self.args = {}
for k, v in args.items():
-
k = k.replace('__', ':')
-
k = k.replace('_', '-')
-
if k[-1] == '-':
-
k = k[:-1]
-
self.args[k] = v
self.children = []
self.ordered_children = defaultdict(list)
def check_children_allowed(self):
if not self.has_content:
raise RuntimeError(
'{} does not support children'.format(type(self)))
-
def all_children(self):
'''Return self.children and self.ordered_children as a single list.'''
output = list(self.children)
for z in sorted(self.ordered_children):
output.extend(self.ordered_children[z])
return output
@property
def id(self):
return self.args.get('id', None)
def write_svg_element(self, id_map, is_duplicate, output_file, context,
dry_run, force_dup=False):
-
children = self.all_children()
if dry_run:
if is_duplicate(self) and self.id is None:
id_map[id(self)]
···
if self.has_content:
self.write_content(
id_map, is_duplicate, output_file, context, dry_run)
-
if children:
self.write_children_content(
id_map, is_duplicate, output_file, context, dry_run)
return
if is_duplicate(self) and not force_dup:
mapped_id = self.id
-
if id_map and id(self) in id_map:
mapped_id = id_map[id(self)]
output_file.write('<use xlink:href="#{}" />'.format(mapped_id))
return
···
override_args = dict(override_args)
override_args['id'] = id_map[id(self)]
context.write_tag_args(override_args, output_file, id_map)
-
if not self.has_content and not children:
output_file.write(' />')
else:
output_file.write('>')
if self.has_content:
self.write_content(
id_map, is_duplicate, output_file, context, dry_run)
-
if children:
self.write_children_content(
id_map, is_duplicate, output_file, context, dry_run)
output_file.write('</')
···
This will not be called if has_content is False.
'''
-
children = self.all_children()
if dry_run:
for child in children:
child.write_svg_element(
···
dry_run):
super().write_svg_defs(
id_map, is_duplicate, output_file, context, dry_run)
-
for child in self.all_children():
child.write_svg_defs(
id_map, is_duplicate, output_file, context, dry_run)
def __eq__(self, other):
···
self.children.extend(animate_iterable)
def append_title(self, text, **kwargs):
self.children.append(elements.Title(text, **kwargs))
class DrawingParentElement(DrawingBasicElement):
···
def __init__(self, children=(), ordered_children=None, **args):
super().__init__(**args)
self.children = list(children)
-
if ordered_children:
self.ordered_children.update(
(z, list(v)) for z, v in ordered_children.items())
if self.children or self.ordered_children:
···
if not hasattr(obj, 'write_svg_element'):
elements = obj.to_drawables(**kwargs)
else:
-
assert len(kwargs) == 0
elements = obj
if hasattr(elements, 'write_svg_element'):
self.append(elements, z=z)
···
def write_content(self, id_map, is_duplicate, output_file, context,
dry_run):
pass
···
+
from typing import Optional, Sequence, Union
+
from collections import defaultdict
import dataclasses
from . import elements
+
from .native_animation import SyncedAnimationConfig, AnimationHelperData
+
@dataclasses.dataclass(frozen=True)
class Context:
'''Additional drawing configuration that can modify element's SVG output.'''
invert_y: bool = False
+
animation_config: Optional[SyncedAnimationConfig] = None
def drawing_creation_hook(self, d):
'''Called by Drawing on initialization.'''
+
if self.animation_config:
+
self.animation_config.drawing_creation_hook(d, context=self)
def override_view_box(self, view_box):
if self.invert_y:
···
def override_args(self, args):
args = dict(args)
if self.invert_y:
+
for y_like_arg in ('cy', 'y1', 'y2'):
+
if y_like_arg in args:
+
# Flip y for circle, ellipse, line, gradient, etc.
+
try:
+
args[y_like_arg] = -args[y_like_arg]
+
except TypeError:
+
pass
if 'y' in args:
# Flip y for most elements
try:
···
if v is None: continue
if isinstance(v, DrawingElement):
mapped_id = v.id
+
if id_map is not None and id(v) in id_map:
mapped_id = id_map[id(v)]
if mapped_id is None:
continue
···
def __init__(self, **args):
self.args = {}
for k, v in args.items():
+
self.args[normalize_attribute_name(k)] = v
self.children = []
self.ordered_children = defaultdict(list)
+
self.animation_data = AnimationHelperData()
+
self._cached_context = None
+
self._cached_extra_children_with_context = None
def check_children_allowed(self):
if not self.has_content:
raise RuntimeError(
'{} does not support children'.format(type(self)))
+
def _extra_children_with_context_avoid_recompute(self, context=None):
+
if (self._cached_extra_children_with_context is not None
+
and self._cached_context == context):
+
return self._cached_extra_children_with_context
+
self._cached_context = context
+
self._cached_extra_children_with_context = (
+
self.extra_children_with_context(context))
+
return self._cached_extra_children_with_context
+
def extra_children_with_context(self, context=None):
+
return self.animation_data.children_with_context(context)
+
def all_children(self, context=None):
'''Return self.children and self.ordered_children as a single list.'''
output = list(self.children)
for z in sorted(self.ordered_children):
output.extend(self.ordered_children[z])
+
output.extend(
+
self._extra_children_with_context_avoid_recompute(context))
return output
@property
def id(self):
return self.args.get('id', None)
def write_svg_element(self, id_map, is_duplicate, output_file, context,
dry_run, force_dup=False):
+
children = self.all_children(context=context)
if dry_run:
if is_duplicate(self) and self.id is None:
id_map[id(self)]
···
if self.has_content:
self.write_content(
id_map, is_duplicate, output_file, context, dry_run)
+
if children is not None and len(children):
self.write_children_content(
id_map, is_duplicate, output_file, context, dry_run)
return
if is_duplicate(self) and not force_dup:
mapped_id = self.id
+
if id_map is not None and id(self) in id_map:
mapped_id = id_map[id(self)]
output_file.write('<use xlink:href="#{}" />'.format(mapped_id))
return
···
override_args = dict(override_args)
override_args['id'] = id_map[id(self)]
context.write_tag_args(override_args, output_file, id_map)
+
if not self.has_content and (children is None or len(children) == 0):
output_file.write(' />')
else:
output_file.write('>')
if self.has_content:
self.write_content(
id_map, is_duplicate, output_file, context, dry_run)
+
if children is not None and len(children):
self.write_children_content(
id_map, is_duplicate, output_file, context, dry_run)
output_file.write('</')
···
This will not be called if has_content is False.
'''
+
children = self.all_children(context=context)
if dry_run:
for child in children:
child.write_svg_element(
···
dry_run):
super().write_svg_defs(
id_map, is_duplicate, output_file, context, dry_run)
+
for child in self.all_children(context=context):
child.write_svg_defs(
id_map, is_duplicate, output_file, context, dry_run)
def __eq__(self, other):
···
self.children.extend(animate_iterable)
def append_title(self, text, **kwargs):
self.children.append(elements.Title(text, **kwargs))
+
def add_key_frame(self, time, animation_args=None, **attr_values):
+
self._cached_extra_children_with_context = None
+
self.animation_data.add_key_frame(
+
time, animation_args=animation_args, **attr_values)
+
def add_attribute_key_sequence(self, attr, times, values, *,
+
animation_args=None):
+
self._cached_extra_children_with_context = None
+
self.animation_data.add_attribute_key_sequence(
+
attr, times, values, animation_args=animation_args)
class DrawingParentElement(DrawingBasicElement):
···
def __init__(self, children=(), ordered_children=None, **args):
super().__init__(**args)
self.children = list(children)
+
if ordered_children is not None and len(ordered_children):
self.ordered_children.update(
(z, list(v)) for z, v in ordered_children.items())
if self.children or self.ordered_children:
···
if not hasattr(obj, 'write_svg_element'):
elements = obj.to_drawables(**kwargs)
else:
+
if len(kwargs) > 0:
+
raise ValueError('unexpected kwargs')
elements = obj
if hasattr(elements, 'write_svg_element'):
self.append(elements, z=z)
···
def write_content(self, id_map, is_duplicate, output_file, context,
dry_run):
pass
+
+
+
def normalize_attribute_name(name):
+
name = name.replace('__', ':')
+
name = name.replace('_', '-')
+
if name[-1] == '-':
+
name = name[:-1]
+
return name
+164
examples/playback-controls.html
···
···
+
<!DOCTYPE html>
+
<head>
+
<meta charset="utf-8">
+
</head>
+
<body>
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+
width="400" height="200" viewBox="-200.0 -100.0 400 200" onload="svgOnLoad(event)">
+
<defs>
+
</defs>
+
<rect x="-200" y="-100" width="400" height="200" fill="#eee" />
+
<circle cx="0" cy="0" r="40" fill="green" />
+
<circle cx="0" cy="0" r="0" fill="silver" stroke="gray">
+
<animate attributeName="cx" dur="8s" values="-100;0;100;0;-100" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" />
+
<animate attributeName="cy" dur="8s" values="0;-100;0;100;0" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" />
+
<animate attributeName="r" dur="8s" values="0;40;0;40;0" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" />
+
<animate attributeName="stroke-width" dur="8s" values="0;5;0;5;0" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" />
+
</circle>
+
<text x="0" y="1" font-size="30" fill="yellow" text-anchor="middle" dominant-baseline="central">0<animate attributeName="visibility" dur="8s" values="visible;hidden;hidden" keyTimes="0;0.25;1" repeatCount="indefinite" fill="freeze" /></text>
+
<text x="0" y="1" font-size="30" fill="yellow" text-anchor="middle" dominant-baseline="central">1<animate attributeName="visibility" dur="8s" values="hidden;visible;hidden;hidden" keyTimes="0;0.25;0.5;1" repeatCount="indefinite" fill="freeze" /></text>
+
<text x="0" y="1" font-size="30" fill="yellow" text-anchor="middle" dominant-baseline="central">2<animate attributeName="visibility" dur="8s" values="hidden;hidden;visible;hidden;hidden" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" /></text>
+
<text x="0" y="1" font-size="30" fill="yellow" text-anchor="middle" dominant-baseline="central">3<animate attributeName="visibility" dur="8s" values="hidden;hidden;visible;visible" keyTimes="0;0.5;0.75;1" repeatCount="indefinite" fill="freeze" /></text>
+
<g id="scrub">
+
<path d="M-168.0,90.0 L168.0,90.0" stroke="#ccc" stroke-width="4" stroke-linecap="round" />
+
<rect x="-168.0" y="90.0" width="0" height="0.001" stroke="#05f" stroke-width="4" stroke-linejoin="round">
+
<animate attributeName="width" dur="8s" values="0;336" keyTimes="0;1" repeatCount="indefinite" fill="freeze" />
+
</rect>
+
<g id="scrub-capture" data-xmin="-168.0" data-xmax="168.0" data-totaldur="8" data-startdelay="0" data-enddelay="0" data-pauseonload="0">
+
<rect x="-170.0" y="80.0" width="340" height="20" fill="rgba(255,255,255,0)" />
+
<circle cx="-168.0" cy="90.0" r="6" fill="#05f" id="scrub-knob" visibility="hidden">
+
<animate attributeName="cx" dur="8s" values="-168.0;168.0" keyTimes="0;1" repeatCount="indefinite" fill="freeze" />
+
</circle>
+
</g>
+
<g id="scrub-play" visibility="hidden">
+
<rect x="-191.0" y="86.0" width="8" height="8" fill="#05f" stroke="#05f" stroke-width="8" stroke-linejoin="round" />
+
<path d="M-191.0,86.0 v8.0 l8.0,-4.0 Z" fill="#eee" />
+
</g>
+
<g id="scrub-pause" visibility="hidden">
+
<rect x="-191.0" y="86.0" width="8" height="8" fill="#05f" stroke="#05f" stroke-width="8" stroke-linejoin="round" />
+
<rect x="-190.0" y="86.0" width="2.0" height="8.0" fill="#eee" />
+
<rect x="-186.0" y="86.0" width="2.0" height="8.0" fill="#eee" />
+
</g>
+
</g>
+
<script>/*<![CDATA[*/
+
/* Animation playback controls generated by drawsvg */
+
/* https://github.com/cduck/drawsvg/ */
+
function svgOnLoad(event) {
+
/* Support standalone SVG or embedded in HTML or iframe */
+
if (event && event.target && event.target.ownerDocument) {
+
svgSetup(event.target.ownerDocument);
+
} else if (document && document.currentScript
+
&& document.currentScript.parentElement) {
+
svgSetup(document.currentScript.parentElement);
+
}
+
}
+
function svgSetup(doc) {
+
var svgRoot = doc.documentElement || doc;
+
var scrubCapture = doc.getElementById("scrub-capture");
+
/* Block multiple setups */
+
if (!scrubCapture || scrubCapture.getAttribute("svgSetupDone")) {
+
return;
+
}
+
scrubCapture.setAttribute("svgSetupDone", true);
+
var scrubContainer = doc.getElementById("scrub");
+
var scrubPlay = doc.getElementById("scrub-play");
+
var scrubPause = doc.getElementById("scrub-pause");
+
var scrubKnob = doc.getElementById("scrub-knob");
+
var scrubXMin = parseFloat(scrubCapture.dataset.xmin);
+
var scrubXMax = parseFloat(scrubCapture.dataset.xmax);
+
var scrubTotalDur = parseFloat(scrubCapture.dataset.totaldur);
+
var scrubStartDelay = parseFloat(scrubCapture.dataset.startdelay);
+
var scrubEndDelay = parseFloat(scrubCapture.dataset.enddelay);
+
var scrubPauseOnLoad = parseFloat(scrubCapture.dataset.pauseonload);
+
var paused = false;
+
var dragXOffset = 0;
+
var point = svgRoot.createSVGPoint();
+
+
function screenToSvgX(p) {
+
var matrix = scrubKnob.getScreenCTM().inverse();
+
point.x = p.x;
+
point.y = p.y;
+
return point.matrixTransform(matrix).x;
+
};
+
function screenToProgress(p) {
+
var matrix = scrubKnob.getScreenCTM().inverse();
+
point.x = p.x;
+
point.y = p.y;
+
var x = point.matrixTransform(matrix).x;
+
if (x <= scrubXMin) {
+
return scrubStartDelay / scrubTotalDur;
+
}
+
if (x >= scrubXMax) {
+
return (scrubTotalDur - scrubEndDelay) / scrubTotalDur;
+
}
+
return (scrubStartDelay/scrubTotalDur
+
+ (x - dragXOffset - scrubXMin)
+
/ (scrubXMax - scrubXMin)
+
* (scrubTotalDur - scrubStartDelay - scrubEndDelay)
+
/ scrubTotalDur);
+
};
+
function currentScrubX() {
+
return scrubKnob.cx.animVal.value;
+
};
+
function pause() {
+
svgRoot.pauseAnimations();
+
scrubPlay.setAttribute("visibility", "visible");
+
scrubPause.setAttribute("visibility", "hidden");
+
paused = true;
+
};
+
function play() {
+
svgRoot.unpauseAnimations();
+
scrubPause.setAttribute("visibility", "visible");
+
scrubPlay.setAttribute("visibility", "hidden");
+
paused = false;
+
};
+
function scrub(playbackFraction) {
+
var t = scrubTotalDur * playbackFraction;
+
/* Stop 10ms before end to avoid loop (>=1ms needed on FF) */
+
var limit = scrubTotalDur - 10e-3;
+
if (t < 0) t = 0;
+
else if (t > limit) t = limit;
+
svgRoot.setCurrentTime(t);
+
};
+
function mousedown(e) {
+
svgRoot.pauseAnimations();
+
if (e.target == scrubKnob) {
+
dragXOffset = screenToSvgX(e) - currentScrubX();
+
} else {
+
dragXOffset = 0;
+
}
+
scrub(screenToProgress(e));
+
/* Global document listeners */
+
document.addEventListener('mousemove', mousemove);
+
document.addEventListener('mouseup', mouseup);
+
e.preventDefault();
+
};
+
function mouseup(e) {
+
dragXOffset = 0;
+
document.removeEventListener('mousemove', mousemove);
+
document.removeEventListener('mouseup', mouseup);
+
if (!paused) {
+
svgRoot.unpauseAnimations();
+
}
+
e.preventDefault();
+
};
+
function mousemove(e) {
+
scrub(screenToProgress(e));
+
};
+
scrubPause.addEventListener("click", pause);
+
scrubPlay.addEventListener("click", play);
+
scrubCapture.addEventListener("mousedown", mousedown);
+
scrubContainer.setAttribute("visibility", "visible");
+
scrubKnob.setAttribute("visibility", "visible");
+
if (scrubPauseOnLoad) {
+
pause();
+
scrub(0);
+
} else {
+
play();
+
}
+
};
+
svgOnLoad();
+
/*]]>*/</script>
+
</svg>
+
</body>
+
</html>
+158
examples/playback-controls.svg
···
···
+
<?xml version="1.0" encoding="UTF-8"?>
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+
width="400" height="200" viewBox="-200.0 -100.0 400 200" onload="svgOnLoad(event)">
+
<defs>
+
</defs>
+
<rect x="-200" y="-100" width="400" height="200" fill="#eee" />
+
<circle cx="0" cy="0" r="40" fill="green" />
+
<circle cx="0" cy="0" r="0" fill="silver" stroke="gray">
+
<animate attributeName="cx" dur="8s" values="-100;0;100;0;-100" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" />
+
<animate attributeName="cy" dur="8s" values="0;-100;0;100;0" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" />
+
<animate attributeName="r" dur="8s" values="0;40;0;40;0" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" />
+
<animate attributeName="stroke-width" dur="8s" values="0;5;0;5;0" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" />
+
</circle>
+
<text x="0" y="1" font-size="30" fill="yellow" text-anchor="middle" dominant-baseline="central">0<animate attributeName="visibility" dur="8s" values="visible;hidden;hidden" keyTimes="0;0.25;1" repeatCount="indefinite" fill="freeze" /></text>
+
<text x="0" y="1" font-size="30" fill="yellow" text-anchor="middle" dominant-baseline="central">1<animate attributeName="visibility" dur="8s" values="hidden;visible;hidden;hidden" keyTimes="0;0.25;0.5;1" repeatCount="indefinite" fill="freeze" /></text>
+
<text x="0" y="1" font-size="30" fill="yellow" text-anchor="middle" dominant-baseline="central">2<animate attributeName="visibility" dur="8s" values="hidden;hidden;visible;hidden;hidden" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" fill="freeze" /></text>
+
<text x="0" y="1" font-size="30" fill="yellow" text-anchor="middle" dominant-baseline="central">3<animate attributeName="visibility" dur="8s" values="hidden;hidden;visible;visible" keyTimes="0;0.5;0.75;1" repeatCount="indefinite" fill="freeze" /></text>
+
<g id="scrub">
+
<path d="M-168.0,90.0 L168.0,90.0" stroke="#ccc" stroke-width="4" stroke-linecap="round" />
+
<rect x="-168.0" y="90.0" width="0" height="0.001" stroke="#05f" stroke-width="4" stroke-linejoin="round">
+
<animate attributeName="width" dur="8s" values="0;336" keyTimes="0;1" repeatCount="indefinite" fill="freeze" />
+
</rect>
+
<g id="scrub-capture" data-xmin="-168.0" data-xmax="168.0" data-totaldur="8" data-startdelay="0" data-enddelay="0" data-pauseonload="0">
+
<rect x="-170.0" y="80.0" width="340" height="20" fill="rgba(255,255,255,0)" />
+
<circle cx="-168.0" cy="90.0" r="6" fill="#05f" id="scrub-knob" visibility="hidden">
+
<animate attributeName="cx" dur="8s" values="-168.0;168.0" keyTimes="0;1" repeatCount="indefinite" fill="freeze" />
+
</circle>
+
</g>
+
<g id="scrub-play" visibility="hidden">
+
<rect x="-191.0" y="86.0" width="8" height="8" fill="#05f" stroke="#05f" stroke-width="8" stroke-linejoin="round" />
+
<path d="M-191.0,86.0 v8.0 l8.0,-4.0 Z" fill="#eee" />
+
</g>
+
<g id="scrub-pause" visibility="hidden">
+
<rect x="-191.0" y="86.0" width="8" height="8" fill="#05f" stroke="#05f" stroke-width="8" stroke-linejoin="round" />
+
<rect x="-190.0" y="86.0" width="2.0" height="8.0" fill="#eee" />
+
<rect x="-186.0" y="86.0" width="2.0" height="8.0" fill="#eee" />
+
</g>
+
</g>
+
<script>/*<![CDATA[*/
+
/* Animation playback controls generated by drawsvg */
+
/* https://github.com/cduck/drawsvg/ */
+
function svgOnLoad(event) {
+
/* Support standalone SVG or embedded in HTML or iframe */
+
if (event && event.target && event.target.ownerDocument) {
+
svgSetup(event.target.ownerDocument);
+
} else if (document && document.currentScript
+
&& document.currentScript.parentElement) {
+
svgSetup(document.currentScript.parentElement);
+
}
+
}
+
function svgSetup(doc) {
+
var svgRoot = doc.documentElement || doc;
+
var scrubCapture = doc.getElementById("scrub-capture");
+
/* Block multiple setups */
+
if (!scrubCapture || scrubCapture.getAttribute("svgSetupDone")) {
+
return;
+
}
+
scrubCapture.setAttribute("svgSetupDone", true);
+
var scrubContainer = doc.getElementById("scrub");
+
var scrubPlay = doc.getElementById("scrub-play");
+
var scrubPause = doc.getElementById("scrub-pause");
+
var scrubKnob = doc.getElementById("scrub-knob");
+
var scrubXMin = parseFloat(scrubCapture.dataset.xmin);
+
var scrubXMax = parseFloat(scrubCapture.dataset.xmax);
+
var scrubTotalDur = parseFloat(scrubCapture.dataset.totaldur);
+
var scrubStartDelay = parseFloat(scrubCapture.dataset.startdelay);
+
var scrubEndDelay = parseFloat(scrubCapture.dataset.enddelay);
+
var scrubPauseOnLoad = parseFloat(scrubCapture.dataset.pauseonload);
+
var paused = false;
+
var dragXOffset = 0;
+
var point = svgRoot.createSVGPoint();
+
+
function screenToSvgX(p) {
+
var matrix = scrubKnob.getScreenCTM().inverse();
+
point.x = p.x;
+
point.y = p.y;
+
return point.matrixTransform(matrix).x;
+
};
+
function screenToProgress(p) {
+
var matrix = scrubKnob.getScreenCTM().inverse();
+
point.x = p.x;
+
point.y = p.y;
+
var x = point.matrixTransform(matrix).x;
+
if (x <= scrubXMin) {
+
return scrubStartDelay / scrubTotalDur;
+
}
+
if (x >= scrubXMax) {
+
return (scrubTotalDur - scrubEndDelay) / scrubTotalDur;
+
}
+
return (scrubStartDelay/scrubTotalDur
+
+ (x - dragXOffset - scrubXMin)
+
/ (scrubXMax - scrubXMin)
+
* (scrubTotalDur - scrubStartDelay - scrubEndDelay)
+
/ scrubTotalDur);
+
};
+
function currentScrubX() {
+
return scrubKnob.cx.animVal.value;
+
};
+
function pause() {
+
svgRoot.pauseAnimations();
+
scrubPlay.setAttribute("visibility", "visible");
+
scrubPause.setAttribute("visibility", "hidden");
+
paused = true;
+
};
+
function play() {
+
svgRoot.unpauseAnimations();
+
scrubPause.setAttribute("visibility", "visible");
+
scrubPlay.setAttribute("visibility", "hidden");
+
paused = false;
+
};
+
function scrub(playbackFraction) {
+
var t = scrubTotalDur * playbackFraction;
+
/* Stop 10ms before end to avoid loop (>=1ms needed on FF) */
+
var limit = scrubTotalDur - 10e-3;
+
if (t < 0) t = 0;
+
else if (t > limit) t = limit;
+
svgRoot.setCurrentTime(t);
+
};
+
function mousedown(e) {
+
svgRoot.pauseAnimations();
+
if (e.target == scrubKnob) {
+
dragXOffset = screenToSvgX(e) - currentScrubX();
+
} else {
+
dragXOffset = 0;
+
}
+
scrub(screenToProgress(e));
+
/* Global document listeners */
+
document.addEventListener('mousemove', mousemove);
+
document.addEventListener('mouseup', mouseup);
+
e.preventDefault();
+
};
+
function mouseup(e) {
+
dragXOffset = 0;
+
document.removeEventListener('mousemove', mousemove);
+
document.removeEventListener('mouseup', mouseup);
+
if (!paused) {
+
svgRoot.unpauseAnimations();
+
}
+
e.preventDefault();
+
};
+
function mousemove(e) {
+
scrub(screenToProgress(e));
+
};
+
scrubPause.addEventListener("click", pause);
+
scrubPlay.addEventListener("click", play);
+
scrubCapture.addEventListener("mousedown", mousedown);
+
scrubContainer.setAttribute("visibility", "visible");
+
scrubKnob.setAttribute("visibility", "visible");
+
if (scrubPauseOnLoad) {
+
pause();
+
scrub(0);
+
} else {
+
play();
+
}
+
};
+
svgOnLoad();
+
/*]]>*/</script>
+
</svg>