Dynamic SVG for Wikimedia projects:

Exploring applications, techniques and best practice for interactive and animated vector graphics

[[:en:user:cmglee]]

Techniques

 
CSS animation
Technology Power Compatibility
JavaScript/ECMAScript
  • Almost limitless
Barred for Wikimedia
Cascading Style Sheets (CSS)
  • Hover effects
Most modern browsers
  • Animation
Not in Internet Explorer
Synchronized Multimedia Integration Language (SMIL)
  • Hover & click effects
  • Animation
Not in Internet Explorer
 
SMIL animation

Animation

 
A very long animation using SMIL
  1. Has much smaller file size
  2. Can be enlarged without getting blocky
  3. Allows interaction besides pause and seek
 
CSS animation highlighting features of interest on hover and linking to Wikipedia on click
 
SMIL animation demonstrating change in transformation and CSS attributes

Error: Image is invalid or non-existent.

Tooltips

 
Using multiline titles with Chinese characters and highlighting on hover
Technique Power Compatibility
Title tag
  • Text (multiline not in IE)
  • Follows cursor
Most modern browsers
Anchor link
  • Text
  • Ugly
  • Fixed position
Most modern browsers
Embedded custom cursor
  • Image
  • Follows cursor
Not in IE
CSS hover selector
  • Any SVG
  • Fixed position
Most modern browsers
 
Using custom cursors to show number of units on hover
 
Using titles and anchor links to show digits' decimal place (position) on hover
 
Using CSS and titles on hover
 
Airline codes are hyperlinked to their Wikipedia articles
  • Supported on most browsers
  • Specify target to load in a new tab/window
 
Geographic coordinates linked to GeoHack

Progressive disclosure

 
Highlighting on hover using CSS
Technique Power Compatibility
CSS hover selector
  • React to hover
Most modern browsers
SMIL click event
  • React to click
  • Grouping has limitations
Not in IE
 
Interactive plot with groups of selectable objects
 
Selectable objects using SMIL
 
Demonstration using groups

Applications

Interactive timelines

 
Hover over the legend (CSS)
  • Show change to a system over time
  • Add triggers (including invisible ones) to change element appearance
  • CSS for hover effects
  • SMIL for click effects
 
Hover over or click buttons on the timeline (SMIL + CSS)

Simple 3D viewer

 
A large composite of multiple frames tracks the mouse pointer (SMIL)
  • As above, but change viewpoint instead of time
  • JavaScript, Flash or plug-ins not needed, but angles are limited and resolution is low for file size
  • Good for real photographs as number of angles is naturally limited
 
Pointer position controls pseudo-3D rotation angle (SMIL)

GIF animation to SVG converter

 
History
  • Converts a GIF animation into an interactive timeline/simple 3D view
  • Command-line Python 2 script using ImageMagick, tested on Ubuntu and Win 7
  • Usage:
gif_animation_to_svg.py
  <URL of GIF animation file>
  [<use every nth GIF frame;
             negative reverses order>]
  [<action message or '3D' or 'time'>]
  • Uses CSS (no SMIL) so works in most modern browsers
 
Chemistry
 
Astrophysics
 
Geography
Source code for Python 2:
#!/usr/bin/env python
import re, json

## http://stackoverflow.com/questions/3503879
import subprocess, sys
def system(command, is_verbose=False):
 if (is_verbose): sys.stdout.write(command) ## write omits newline
 stdout = subprocess.check_output(command, shell=True)
 if (is_verbose): print(": " + stdout)
 return stdout

import os.path ## to check if file exists
def mkdir_cache(is_refresh_cache=False, is_verbose=False, suffix='.cache/'):
 basename = __file__[:__file__.rfind('.')]
 dir_cache = basename + suffix
 if (is_refresh_cache):
  for (dir, dirs, filenames) in os.walk(dir_cache, topdown=False): os.rmdir(dir)
  if (is_verbose): print("delete {dir_cache}".format(**locals()))
 if (not os.path.exists(dir_cache)):
  if (is_verbose): print("make {dir_cache}".format(**locals()))
  os.makedirs(dir_cache)
 elif (is_verbose): print("{dir_cache} already exists".format(**locals()))
 return dir_cache

## http://www.techrepublic.com/article/parsing-data-from-the-web-in-python/
import urllib2, time ## urllib2 for web access, time for sleep
def read_webpage(url, path_cache='', is_refresh_cache=False, is_verbose=False):
 dir_cache = mkdir_cache(is_refresh_cache=is_refresh_cache, is_verbose=is_verbose)
 if (not path_cache): path_cache = dir_cache + urllib2.quote(url, safe='')
 if (is_refresh_cache or (not os.path.isfile(path_cache))):
  html = urllib2.urlopen(url).read()
  file_html = open(path_cache, 'wb')
  file_html.write(html)
  if (is_verbose): print("fetch {url} into {path_cache}".format(**locals()))
  time.sleep(1) ## avoid rate-limit-exceeded error
 else:
  file_html = open(path_cache)
  html = file_html.read()
  if (is_verbose): print("read from {path_cache}".format(**locals()))
 file_html.close()
 return html

## http://stackoverflow.com/questions/3715493
import base64
def base64_encode(path):
 with open(path, 'rb') as file: return base64.b64encode(file.read())

def make_svg(url, increment, message_action):
 if (message_action == '3D'  ): message_action = 'to rotate the 3D model'
 if (message_action == 'time'): message_action = 'to move through time'
 ## Get image URL if description page URL given
 dir_cache   = mkdir_cache()
 filename    = url[url.rfind('/')+1:]
 if (filename.lower().find('file:') == 0):
  filename  = filename[filename.rfind(':') + 1:]
  path_html = '{dir_cache}{filename}.htm'.format(**locals())
  html      = read_webpage(url, path_html, is_verbose=True)
  url       = re.search(r'http.*?//upload\.[^"]+', html).group(0)
 ## Fetch image if needed
 basename      = filename[:filename.rfind('.')]
 path_gif      = dir_cache + filename
 path_basename = path_gif[:path_gif.rfind('.')]
 read_webpage(url, path_gif, is_verbose=True)
 ## Extract GIF animation frames if needed
 if (os.path.isfile('{dir_cache}{basename}-0.png'.format(**locals()))):
  print("skip extracting GIF animation frames")
 else:
  print("extract GIF animation frames")
  system('magick "{path_gif}" -coalesce "{path_basename}.png"'.format(**locals()), is_verbose=True)
 ## Base64-encode frames if needed
 path_json    = dir_cache + basename + '.json'
 jsons        = {}
 n_image      = 0
 n_frame      = 0
 if (0):
 # if (os.path.isfile(path_json)):
  file_json    = open(path_json, 'r')
  jsons        = json.loads(file_json.read())
  n_frame      = jsons['n_frame']
  out_image    = jsons['out_image']
  width_image  = jsons['width_image']
  height_image = jsons['height_image']
 else:
  ## Count frames
  n_image = 0
  while (os.path.isfile('{dir_cache}{basename}-{n_image}.png'.format(**locals()))): n_image += 1
  ## Base64-encode relevant frames
  n_frame    = int(n_image / abs(increment))
  out_images = []
  for i_frame in range(n_frame):
   i_image    = i_frame * increment + (0 if (increment > 0) else n_image + increment)
   path_frame = '{dir_cache}{basename}-{i_image}.png'.format(**locals())
   stdout     = system('magick "{path_frame}" info:'.format(**locals()), is_verbose=True)
   (width_image, height_image) = [int(dim) for dim in re.search(r'\d+x\d+', stdout).group(0).split('x')]
   base64_encoded = base64_encode(path_frame)
   out_images.append('''\
  <image id="image_{i_frame}" x="0" y="0" width="{width_image}" height="{height_image}" xlink:href="data:image/png;base64,{base64_encoded}"/>\
'''.format(**locals()))
  out_image = '\n'.join(out_images)
  jsons     = {'out_image':out_image, 'width_image' :width_image,
               'n_frame'  :n_frame  , 'height_image':height_image}
  file_json = open(path_json, 'w')
  try: ## use try/finally so that file is closed even if write fails
   file_json.write(json.dumps(jsons, indent=1, separators=(',',':')))
  finally:
   file_json.close()
 ## Create SVG
 out_mains = []
 scale_thumbnail = round(1.0 / n_frame, 5)
 height_trigger  = int(height_image * (scale_thumbnail + 1) + 0.5)
 width_trigger   = round(width_image * scale_thumbnail, 2)
 width_thumbnail = int(width_trigger + 0.9999)
 for i_frame in range(n_frame):
  x_trigger = round(i_frame * width_trigger, 2)
  out_mains.append('''\
  <g class="frame">
   <g class="content">
    <use xlink:href="#image_{i_frame}"/>
   </g>
   <g class="trigger" transform="translate({x_trigger},{height_image})">
    <use xlink:href="#image_{i_frame}" transform="scale({scale_thumbnail})"/>
    <use xlink:href="#triggers"/>
   </g>
   <!-- <title>frame {i_frame}</title> -->
  </g>'''.format(**locals()))
 out_main         = '\n'.join(out_mains)
 title            = basename.replace('_', ' ')
 stroke_width     = max(width_image, height_image) / 200
 font_size        = width_image  / 20
 x_help           = width_image  / 2
 y_help           = height_image / 2
 height_thumbnail = height_image * scale_thumbnail - stroke_width / 2
 width_images     = [width_image * multiple for multiple in range(99)]
 ## Compile everything into an .svg file
 file_out = open(basename + '.svg', 'w')
 try: ## use try/finally so that file is closed even if write fails
  print("write SVG")
  file_out.write('''<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 {width_image} {height_trigger}">
 <title>{title}</title>
 <desc>Interactive SVG by CMG Lee of the GIF animation at {url} . Move left and right over the SVG image {message_action}.</desc>
 <style type="text/css">
  #main {{ font-family:Helvetica,Arial,sans-serif; font-size:{font_size}px; text-anchor:middle;
           stroke-width:{stroke_width}; fill:#000000; }}
  #trigger              {{ stroke:none; fill-opacity:0; }}
  .frame       .content {{ visibility:hidden; pointer-events:none; fill:#000000; }}
  .frame       .trigger {{ opacity:0.5; cursor:ew-resize; }}
  .frame:hover .content {{ visibility:visible; }}
  .frame:hover .trigger {{ opacity:1; pointer-events:auto; font-weight:bold; stroke:#ff0000; }}
 </style>
 <defs>
  <g id="help">
   <text x="{x_help}" y="{y_help}" dy="-1ex">Move left and right</text>
   <text x="{x_help}" y="{y_help}" dy="1ex">{message_action}</text>
  </g>
  <rect id="trigger" x="0" y="-4999" width="{width_thumbnail}" height="9999"/>
  <g id="triggers">
   <rect x="0" y="0" width="{width_thumbnail}" height="{height_thumbnail}" fill="none"/>
   <use xlink:href="#trigger"/>
   <use xlink:href="#trigger" transform="translate(-{width_images[1]},0)"/>
   <use xlink:href="#trigger" transform="translate( {width_images[1]},0)"/>
   <use xlink:href="#trigger" transform="translate(-{width_images[2]},0)"/>
   <use xlink:href="#trigger" transform="translate( {width_images[2]},0)"/>
   <use xlink:href="#trigger" transform="translate(-{width_images[3]},0)"/>
   <use xlink:href="#trigger" transform="translate( {width_images[3]},0)"/>
   <use xlink:href="#trigger" transform="translate(-{width_images[4]},0)"/>
   <use xlink:href="#trigger" transform="translate( {width_images[4]},0)"/>
  </g>
{out_image}
 </defs>
 <g id="main">
  <circle cx="0" cy="0" r="9999" fill="#ffffff"/>
  <use xlink:href="#image_0" opacity="0.5"/>
  <use xlink:href="#help" stroke-opacity="0.5" stroke="#ffffff"/>
  <use xlink:href="#help"/>
  <g id="frames">
{out_main}
  </g>
 </g>
</svg>
'''.format(**locals()))
 finally:
  file_out.close()

n_argv = len(sys.argv)
if (n_argv < 2):
 print(("usage: {sys.argv[0]} <URL of GIF animation file> [<use every nth GIF frame;" +
        " negative reverses order>] [<action message or '3D' or 'time'>]")
       .format(**locals()))
else:
 make_svg(    sys.argv[1],
          int(sys.argv[2]) if (n_argv > 2) else 1,
              sys.argv[3]  if (n_argv > 3) else '3D')
Source code for Python 3:
#!/usr/bin/env python
import re, json

## http://stackoverflow.com/questions/3503879
import subprocess, sys
def system(command, is_verbose=False):
 if (is_verbose): sys.stdout.write(command) ## write omits newline
 stdout = str(subprocess.check_output(command, shell=True))
 if (is_verbose): print(": " + stdout)
 return stdout

import os.path ## to check if file exists
def mkdir_cache(is_refresh_cache=False, is_verbose=False, suffix='.cache/'):
 basename = __file__[:__file__.rfind('.')]
 dir_cache = basename + suffix
 if (is_refresh_cache):
  for (dir, dirs, filenames) in os.walk(dir_cache, topdown=False): os.rmdir(dir)
  if (is_verbose): print("delete {dir_cache}".format(**locals()))
 if (not os.path.exists(dir_cache)):
  if (is_verbose): print("make {dir_cache}".format(**locals()))
  os.makedirs(dir_cache)
 elif (is_verbose): print("{dir_cache} already exists".format(**locals()))
 return dir_cache

## http://www.techrepublic.com/article/parsing-data-from-the-web-in-python/
import urllib.request as urllib2, time ## urllib2 for web access, time for sleep
def read_webpage(url, path_cache='', is_refresh_cache=False, is_verbose=False):
 dir_cache = mkdir_cache(is_refresh_cache=is_refresh_cache, is_verbose=is_verbose)
 if (not path_cache): path_cache = dir_cache + urllib2.quote(url, safe='')
 if (is_refresh_cache or (not os.path.isfile(path_cache))):
  html = urllib2.urlopen(url).read()
  file_html = open(path_cache, 'wb')
  file_html.write(html)
  if (is_verbose): print("fetch {url} into {path_cache}".format(**locals()))
  time.sleep(1) ## avoid rate-limit-exceeded error
 else:
  file_html = open(path_cache, 'rb')
  html = file_html.read()
  if (is_verbose): print("read from {path_cache}".format(**locals()))
 file_html.close()
 return html

## http://stackoverflow.com/questions/3715493
import base64
def base64_encode(path):
 with open(path, 'rb') as file: return base64.b64encode(file.read()).decode('ascii')

def make_svg(url, increment, message_action):
 if (message_action == '3D'  ): message_action = 'to rotate the 3D model'
 if (message_action == 'time'): message_action = 'to move through time'
 ## Get image URL if description page URL given
 dir_cache   = mkdir_cache()
 filename    = url[url.rfind('/')+1:]
 if (filename.lower().find('file:') == 0):
  filename  = filename[filename.rfind(':') + 1:]
  path_html = '{dir_cache}{filename}.htm'.format(**locals())
  html      = read_webpage(url, path_html, is_verbose=True)
  url       = re.search(r'http.*?//upload\.[^"]+', html).group(0)
  print(url)
 ## Fetch image if needed
 basename      = filename[:filename.rfind('.')]
 path_gif      = dir_cache + filename
 path_basename = path_gif[:path_gif.rfind('.')]
 read_webpage(url, path_gif, is_verbose=True)
 ## Extract GIF animation frames if needed
 if (os.path.isfile('{dir_cache}{basename}-0.png'.format(**locals()))):
  print("skip extracting GIF animation frames")
 else:
  print("extract GIF animation frames")
  system('magick "{path_gif}" -coalesce "{path_basename}.png"'.format(**locals()), is_verbose=True)
 ## Base64-encode frames if needed
 path_json    = dir_cache + basename + '.json'
 jsons        = {}
 n_image      = 0
 n_frame      = 0
 if (0):
 # if (os.path.isfile(path_json)):
  file_json    = open(path_json, 'r')
  jsons        = json.loads(file_json.read())
  n_frame      = jsons['n_frame']
  out_image    = jsons['out_image']
  width_image  = jsons['width_image']
  height_image = jsons['height_image']
 else:
  ## Count frames
  n_image = 0
  while (os.path.isfile('{dir_cache}{basename}-{n_image}.png'.format(**locals()))): n_image += 1
  ## Base64-encode relevant frames
  n_frame    = int(n_image / abs(increment))
  out_images = []
  for i_frame in range(n_frame):
   i_image    = i_frame * increment + (0 if (increment > 0) else n_image + increment)
   path_frame = '{dir_cache}{basename}-{i_image}.png'.format(**locals())
   stdout     = system('magick "{path_frame}" info:'.format(**locals()), is_verbose=True)
   (width_image, height_image) = [int(dim) for dim in re.search(r'\d+x\d+', stdout).group(0).split('x')]
   base64_encoded = base64_encode(path_frame)
   out_images.append('''\
  <image id="image_{i_frame}" x="0" y="0" width="{width_image}" height="{height_image}" xlink:href="data:image/png;base64,{base64_encoded}"/>\
'''.format(**locals()))
  out_image = '\n'.join(out_images)
  jsons     = {'out_image':out_image, 'width_image' :width_image,
               'n_frame'  :n_frame  , 'height_image':height_image}
  file_json = open(path_json, 'w')
  try: ## use try/finally so that file is closed even if write fails
   file_json.write(json.dumps(jsons, indent=1, separators=(',',':')))
  finally:
   file_json.close()
 ## Create SVG
 out_mains = []
 scale_thumbnail = round(1.0 / n_frame, 5)
 height_trigger  = int(height_image * (scale_thumbnail + 1) + 0.5)
 width_trigger   = round(width_image * scale_thumbnail, 2)
 width_thumbnail = int(width_trigger + 0.9999)
 for i_frame in range(n_frame):
  x_trigger = round(i_frame * width_trigger, 2)
  out_mains.append('''\
  <g class="frame">
   <g class="content">
    <use xlink:href="#image_{i_frame}"/>
   </g>
   <g class="trigger" transform="translate({x_trigger},{height_image})">
    <use xlink:href="#image_{i_frame}" transform="scale({scale_thumbnail})"/>
    <use xlink:href="#triggers"/>
   </g>
   <!-- <title>frame {i_frame}</title> -->
  </g>'''.format(**locals()))
 out_main         = '\n'.join(out_mains)
 title            = basename.replace('_', ' ')
 stroke_width     = max(width_image, height_image) / 200
 font_size        = width_image  / 20
 x_help           = width_image  / 2
 y_help           = height_image / 2
 height_thumbnail = height_image * scale_thumbnail - stroke_width / 2
 width_images     = [width_image * multiple for multiple in range(99)]
 ## Compile everything into an .svg file
 file_out = open(basename + '.svg', 'w')
 try: ## use try/finally so that file is closed even if write fails
  print("write SVG")
  file_out.write('''<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 {width_image} {height_trigger}">
 <title>{title}</title>
 <desc>Interactive SVG by CMG Lee of the GIF animation at {url} . Move left and right over the SVG image {message_action}.</desc>
 <style type="text/css">
  #main {{ font-family:Helvetica,Arial,sans-serif; font-size:{font_size}px; text-anchor:middle;
           stroke-width:{stroke_width}; fill:#000000; }}
  #trigger              {{ stroke:none; fill-opacity:0; }}
  .frame       .content {{ visibility:hidden; pointer-events:none; fill:#000000; }}
  .frame       .trigger {{ opacity:0.5; cursor:ew-resize; }}
  .frame:hover .content {{ visibility:visible; }}
  .frame:hover .trigger {{ opacity:1; pointer-events:auto; font-weight:bold; stroke:#ff0000; }}
 </style>
 <defs>
  <g id="help">
   <text x="{x_help}" y="{y_help}" dy="-1ex">Move left and right</text>
   <text x="{x_help}" y="{y_help}" dy="1ex">{message_action}</text>
  </g>
  <rect id="trigger" x="0" y="-4999" width="{width_thumbnail}" height="9999"/>
  <g id="triggers">
   <rect x="0" y="0" width="{width_thumbnail}" height="{height_thumbnail}" fill="none"/>
   <use xlink:href="#trigger"/>
   <use xlink:href="#trigger" transform="translate(-{width_images[1]},0)"/>
   <use xlink:href="#trigger" transform="translate( {width_images[1]},0)"/>
   <use xlink:href="#trigger" transform="translate(-{width_images[2]},0)"/>
   <use xlink:href="#trigger" transform="translate( {width_images[2]},0)"/>
   <use xlink:href="#trigger" transform="translate(-{width_images[3]},0)"/>
   <use xlink:href="#trigger" transform="translate( {width_images[3]},0)"/>
   <use xlink:href="#trigger" transform="translate(-{width_images[4]},0)"/>
   <use xlink:href="#trigger" transform="translate( {width_images[4]},0)"/>
  </g>
{out_image}
 </defs>
 <g id="main">
  <circle cx="0" cy="0" r="9999" fill="#ffffff"/>
  <use xlink:href="#image_0" opacity="0.5"/>
  <use xlink:href="#help" stroke-opacity="0.5" stroke="#ffffff"/>
  <use xlink:href="#help"/>
  <g id="frames">
{out_main}
  </g>
 </g>
</svg>
'''.format(**locals()))
 finally:
  file_out.close()

n_argv = len(sys.argv)
if (n_argv < 2):
 print(("usage: {sys.argv[0]} <URL of GIF animation file> [<use every nth GIF frame;" +
        " negative reverses order>] [<action message or '3D' or 'time'>]")
       .format(**locals()))
else:
 make_svg(    sys.argv[1],
          int(sys.argv[2]) if (n_argv > 2) else 1,
              sys.argv[3]  if (n_argv > 3) else '3D')

Best practice

  • Degrade gracefully on less well-endowed browsers e.g.
    • Fall back on CSS hover effects if SMIL click effects unsupported
    • Check tooltips read fine if newlines are replaced with spaces
  • Touchscreens have no hover: click includes hover
    • To maintain click effect, hyperlink an icon
  • Check thumbnail is OK
  • Add link to SVG file in caption


 
Comparison of the side elevations of some notable bridges at the same scale — in the SVG image, hover over or click a silhouette to highlight it

Cheers

 
Demonstration of motion along a path and simulation of 3D using SMIL
Thank you!

Any questions?

Wanna collaborate?

[[:en:user:cmglee]]

 

 
The 21 game with AI using SMIL
 
A simple action game using SMIL
 
Novel application of CSS timeline