Let's head for the finish line!
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 -->
</rect>
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.
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">
...
</svg>
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.
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:
hSlider
from Prelude) to control a "zoom" parameter relative
to the total window size, andxySlider
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.
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"
values="from;to"
dur="duration"
repeatCount="indefinite or count"></animate>
</nodeToAnimate>
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)
['animate'
[ ['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.
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.↩
If you would like to gain a deeper intuition for how the SVG `viewBox` works, a helpful demo can be found here.↩