Step 5: Additional SVG Functionality

In the previous several lessons, we have discussed SVG features that have some "special" support from Sketch-n-Sketch. In this lesson, we will cover:

  • How to use Little's "thin wrapper" around the full range of SVG nodes and attributes.

Let's head for the finish line!

Thin Wrapper Syntax

As mentioned in the first lesson, the output of a Little program is an SVG node that has type svg. However, while SVG nodes are represented in text form as XML-style nodes and attributes delimited with < and >, SVG nodes in Little are represented by three-element lists of the following form:

[ 'nodeName' attributeList childNodeList ]

Furthermore, each attribute takes the form

[ 'attrName' value ]

where the value can be a string, number, or list depending on what attrName is. No matter what, however, a string value can be used, in which case it is copied directly into the output SVG format. For this reason, we describe our representation of SVG elements as a "thin wrapper" around the full specification, because you can always just use strings. (The benefit of using numbers, lists, etc. whenever possible, however, is that Sketch-n-Sketch editor can provide interactive direct manipulation when these values are not "buried" inside strings.) The nodeName value above can also be any string and, thus, provides access even to SVG elements that are not handled specially by the editor.

Having let the cat out of the bag, we can now think about how to reimplement the rect function ourselves. In raw SVG format, a rectangle is specified in the following fashion:

<rect x="xval" y="yval" width="widthval" height="heightval" fill="colorname">
    <!-- Usually no children -->

Usually, a rectangle has no children, so we may leave the children list empty. Furthermore, the attributes that are absolutely necessary are x, y, width, height, and fill. So, we should make those arguments to our function. Thus, we can specify the following Little function to make a rectangle:

(def ourRect (\(x y w h fill)
  [ 'rect' 
    [ ['x' x]
      ['y' y]
      ['width' w]
      ['height' h]
      ['fill' fill]
  ] ) )

It can be hard to read and write code with these lists of lists everywhere, so sometimes it helps to introduce temporary variables (and to choose whitespace to taste):

(def ourRect (\(x y w h fill)
  (let attrs [['x' x] ['y' y] ['width' w] ['height' h] ['fill' fill]]
  (let children []
    ['rect' attrs children]))))

That's pretty much all there is to the built-in rect function. All of the shape functions in Little are implemented in similar fashion. Take a peek at Prelude to see how the functions line, rect, circle, path, and so on simply provide a nicer syntax than having to write down lists of lists all over the place.

The Prelude functions are defined to take as arguments the most common attributes for each kind of shape. But because SVG elements are really represented using the aforementioned three-element list encoding under the hood, there's nothing stopping you from specifying additional attributes. For example, as mentioned on the documentation page for rect, another common usage is to specify a rectangle with rounded corners.

Exercise 5.1: Modify ourRect so that it allows you to specify the radius of the rounded corners.

Hint: There's a nice helper function in Prelude called addAttr that can be very helpful for creating complicated SVG nodes. Its definition is as follows:

(def addAttr (\([shapeKind oldAttrs children] newAttr)
  [shapeKind (snoc newAttr oldAttrs) children]))

This allows attributes to be appended to existing SVG nodes with ease.1 It is easy to imagine a similar function that allows for the appending of children as well. Furthermore, as attributes and children are just lists with elements that are straightforward to pattern match on with case, there's plenty of functionality for any function to modify any shape in any way.

To illustrate, let's write a function that sets the 'fill' attribute of an SVG node if it isn't there and changes it to the given value if it is:

(defrec setFill_ (\(attrs fill)
  (case attrs
    ([]              [['fill' fill]])
    ([[a v] | rest]  (if (= a 'fill')
                       [[a fill] | rest]
                       [[a v] | (setFill_ rest fill)])))))

(def setFill (\([nodeName attrs children] fill)
  [nodeName (setFill_ attrs fill) children]))

Notice how we need to keep in mind that the attributes themselves are two-element lists inside of lists. This leads to more than a few nested brackets, but the syntax should become familiar after you use it once or twice. Also notice the use of an (if condition thenExp elseExp), which works the same way in Little as you would expect from other languages.

Practice 5.1: Enter the setFill function into a file, and use it to set the fill of one or more shapes. Then, modify the function to have it set a different attribute.

SVG ViewBox

In all of our examples so far, we have used absolute coordinates (in pixels) when defining the sizes and positions of SVG shapes. Because Scalable Vector Graphics are, well, scalable, this is often okay; most SVG editors and viewers can resize these designs appropriately if needed. Sometimes, however, an SVG image with absolute coordinates may not be resized automatically, such as when embedding it into an HTML document.

The best way to guarantee that your image (and any surrounding border spacing) will be scaled appropriately is to specify a viewBox attribute for the top-level svg canvas node, which defines a local coordinate system for the design. The general structure of using a viewBox is that the top-level SVG node ends up as follows:

<svg width="totalWidth" height="totalHeight" viewBox="minX minY maxX maxY">

This creates an internal coordinate system for your design that goes from minX to maxX in the x-direction and minY to maxY in the y-direction. (Often, minX and maxY are both set to 0, effectively making maxX the width of the coordinate system and maxY the height.) This is offset by minX and minY, effectively defining a rectangle within the totalWidth and totalHeight of your working svg definition that is actually shown when it is rendered. If the totalWidth and totalHeight parameters are omitted from the svg definition, then the viewBox implicitly defines the width and height of the image, and the minX and minY parameters can only specify a subset of the image that reaches all the way to width and height. 2

Alright, that was all a bit specific. In general, a good way to think about a viewBox, at least when it comes to exporting your images, is to start by defining your SVG using raw pixel values (as we've been doing in this and previous lessons). Then, when you're ready to export, change your call to svg to a call to svgViewBox with the overall width and height of your image and then right away your image will scale itself to fit its container!

Exercise 5.2: To see this workflow in action, let's convert the Sketch-n-Sketch logo design into one that scales to fit the canvas. Start by selecting the Logo example and create a locally saved version of it. Then, change the appropriate constants so that the logo fits snugly in the top-left corner of the canvas. Next, switch the top-level canvas function call from svg to svgViewBox and choose appropriate additional arguments for it; take a look at the Prelude source to see how the svgViewBox function corresponds to the 'viewBox' attribute discussed above. Lastly, notice how the rendered output scales to fit the canvas no matter what its size is — play with this either by resizing the canvas pane or by resizing the entire browser window.

Another great example of this pattern is in the Bar Graph example, which uses a variable called doneEditing that, if set to true, renders the document using a viewBox and if set to false doesn't. Furthermore, it also doesn't render the helper slider when doneEditing is set to true, making a convenient mechanism to switch between a "working" mode and an "export" mode. In fact, if you're working with any complicated graphic that uses UI widgets, this setup makes for a very good workflow.

Sliding and Panning and Zooming, Oh My!

Next, we will put several bits of knowledge and experience to good use — about viewBox, the thin wrapper syntax, and our custom UI widgets — to allow us to easily pan and zoom around a larger, design within a fixed window. In particular, we will set up:

  • two variables for the "total" width and height of the window,
  • a slider (via hSlider from Prelude) to control a "zoom" parameter relative to the total window size, and
  • a two-dimensonal slider (via xySlider from a built-in example) to control "x-offset" and "y-offset" parameters.

A general skeleton for this pattern is the following:

; -------------------------------------------------------------------
; Currently, xySlider is not currently included in Prelude, so
; copy the definitions of xySlider_ and xySlider here.

(def xySlider_ ( ... ))

(def xySlider  ( ... ))

; -------------------------------------------------------------------
; Define the window size and pan/zoom sliders.

(def [totalWidth totalHeight] [800! 800!])
(def [zoom zoomSlider]
  (hSlider false 50! 200! 300! 0.1! 5! 'Zoom: ' 1))

(def [width height] [(* zoom totalWidth) (* zoom totalHeight)])
(def [[xOffset yOffset] panSlider]
   (xySlider true 50! 250! 50! 250! 0! totalWidth 0! totalHeight
             'X pan: ' 'Y pan: '
             50 50))

; -------------------------------------------------------------------
; The shapes for the design go in here.

(def shapes ( ... ))

; -------------------------------------------------------------------
; The sliders and the design, panned and zoomed as necessary, goes here.

(def viewBoxAttr
  (+ (+ (+ (+ (+ (+ (toString (/ xOffset zoom)) ' ')
                    (toString (/ yOffset zoom))) ' ')
                    (toString totalWidth)) ' ')
                    (toString totalHeight)))

(def scaledDesign
  (let attrs [['viewBox' viewBoxAttr]
              ['width' (toString width)]
              ['height' (toString height)]]
  ['svg' attrs shapes]))

(svg (concat [[scaledDesign] panSlider zoomSlider]))

Practice 5.2: Add some shapes to the shapes definition, making sure that all shapes that you would like to be visible have their dimensions such that they are within the totalWidth and totalHeight coordinates. Then, use the pan and zoom sliders to move around your design. Notice how moving the ball in the xySlider to the lower right causes the image to move to the upper left.

As it is, the pan slider is such that it is meant to allow you to move your viewing "window", which may be larger or smaller than the graphic, around the overall canvas space. The ball in the slider that you manipulate is meant to represent the location of this window, while the box that it is within is meant to represent the total canvas space that the graphic resides in. To achieve this effect, the sliders manipulate the aforementioned minX and minY parameters to the viewBox attribute to change the offset that the contents of that svg tag begins with inside their internal coordinate system. So, if the totalWidth and totalHeight are each 800, and the width and height are 200, an offset of 100 in the x-direction will correspond starting at x-position 400 as defined in the coordinates where the shapes are. As these values don't get scaled when the zoom level is changed, this leads to some perhaps unintuitive behavior when zooming as all the zooming appears to be done either into or out of the top-left corner of the source image.

In general, scaling and panning with viewBox will likely take a bit of fiddling with to get how you would like it — something you won't be able to avoid when working with SVG in any environment until you get familiar with how viewBox works. Before then, usually using the built-in svgViewBox function and some experimentation are sufficient to do the trick.

Exercise 5.4 (Optional): Replace the deeply-nested series of string concatenations in viewBoxAttr with an expression that uses the Prelude functions map and joinStrings; the latter is like concat but for "squishing together" strings instead of lists.

Adding a New SVG Feature

In the zoom-and-pan example, we used the thin wrapper syntax to add "new" attributes to "existing" SVG nodes in Little. But there is nothing from stopping us from using the underlying representation to access different SVG node types altogether. For the last topic of this tutorial, we will demonstrate how to incorporate an SVG element that has currently has no special support from Sketch-n-Sketch. Check out this reference for an overview of all the features in the most recent SVG specification.

We will choose to implement the animate tag with some very simple attributes. As with any additional element that you incorporate into Little, there will be no built-in zones that allow you to directly manipulate its attributes. However, as we seen several times throughout our journey, UI widgets work often work well for manipulating attributes that are not hard-wired in to the editor.

The animate tag, in a simple form, takes the following structure:

<nodeToAnimate itsAttributes>
  <animate  attributeName="attrName"
            from="attrMin" to="attrMax"
            repeatCount="indefinite or count"></animate>

So, to animate any given node over time, we need to add a child animate node with the desired values for each of the attributes above. In our case, we can assume that the attributeType will always be XML, so we won't make that an argument to the animate function below:

(def animate (\(attrName from to dur repeat)
    [ ['attributeName' attrName]
      ['from' (toString from)]
      ['to' (toString to)]
      ['dur' (+ (toString dur) 's')]
      ['values' (+ (+ (toString from) ';') (toString to))]
      ['repeatCount' repeat]
  ] ) )

As with the shape functions from Prelude, this function provides a nicer way of packaging together an SVG node without writing lists everywhere. Even better, when calling the animate function, we can provide numeric values and rely on this function to perform the toString conversions that are needed.

Now, we can create a rectangle that moves in the x-direction:

(def coolRect 
  [ 'rect'
    [ ['x' 100]
      ['y' 100]
      ['fill' 'lightblue']
      ['width' 100]
      ['height' 100]
    [ (animate 'x' 100 200 5 'indefinite') ]

(svg [coolRect])

That's a cool rectangle!

It's then straightforward to turn coolRect into a function that takes an argument for the y-position, so we can draw many animated rectangles on the screen at once:

(def coolRect (\ypos
  [ 'rect'
    [ ['x' 100]
      ['y' ypos]
      ['fill' 'lightblue']
      ['width' 100]
      ['height' 100]
    [ (animate 'x' 100 200 5 'indefinite') ]
) )

(svg (map coolRect [100 210 320 430 540]))

Now, we can programmatically generate animated rectangles with just as much flexibility as we can any other shape, with the ability to change the low-level placement function definitions to boot. Practice 5.3: Try having some of the arguments to animate also depend on ypos and see what happens (changing dur leads to some fun results).

As you can see by toggling the Zones, the zones for these shapes are not where you might expect them to be. Sketch-n-Sketch (currently) expects shapes to remain in the same location, so it does not alter the positions of the zones based on where they are in the animation. Furthermore, there are (currently) no built-in tools to help with animations. So, if working on the edge of what the built-in functionality allows, be ready for a little bit of strange behavior here and there. That being said, our tool is expressive enough to allow for any features of SVG to be expressed and still be able to take advantage of the benefits of programmatically specifying the image. This is part of the benefit of coupling a general-purpose programming language with the "raw" target SVG language.

Exercise 5.4: Now it's your turn. Pick an SVG element or attribute that is not built-in to Sketch-n-Sketch, and write up some Little functions for working with it. Good candidates include the g element, a filter, or a gradient. Don't forget to look at the SVG documentation for inspiration and implementation details.

  1. Currently, we've stuck with the design decision to have the built-in functionality lend itself towards a single statement that creates the SVG node for a given shape or other structure after which that node isn't manipulated any further. There's no reason why things need to be done this way and it's simply how we chose to go about things — a design method that involves creating shapes and then adding attributes to them after the fact is equally as legitimate and just doesn't have a nice group of functions to go along with it in Prelude.

  2. If you would like to gain a deeper intuition for how the SVG viewBox works, a helpful demo can be found here.