svg
Better than canvas
?February 4, 2020
Earlier, I aired my frustrations
with the blurriness of the canvas
element. Maybe SVG (Scalable
Vector Graphics) will work better. In summary, with the right settings, it's not blurry,
but it has its own problems.
Below is a small canvas with a square drawn inside. Click on the square with the mouse to move the square around within the canvas. If you've read the two previous posts (here and here), then you've seen this example before. But now, it's not blurry! If the perimeter of the square seems jittery when it's dragged, zoom in on your browser. This problem will be addressed below, although there's no real fix for it (to my knowledge).
Since we finally have something that works, without being blurry, it's worth
examining what's going on in greater detail. Although it looks better than
canvas
, using SVG makes it more complicated to implement. As usual, look at the page
source for all of the details. I don't use a content management system, so the
source is human-readable, and it's commented.
Shortly, I'll implement this functionality in a different way, but the code
above is organized very much like the
canvas code. There's a drawing area,
based on the svg
tag rather than the canvas
tag, and that
area listens for and responds to mouse events.
The first thing that makes svg
a painful choice compared to
canvas
is the need to allocate the element that draws the square.
Under canvas
, there are no "elements;" when the canvas is redrawn,
you can draw whatever you want – although it does pass through the browser,
which makes it blurry. Under SVG, we need to create an element and insert it into the DOM.
// An <svg> tag was used define the object with id "svgtest" var svgArea = document.getElementById('svgtest'); // Create an SVG path that traces a square. SideLength is a constant. var theSVGSquare=document.createElementNS("http://www.w3.org/2000/svg",'path'); theSVGSquare.setAttributeNS(null, "d", "M 0 0" + " L " + SideLength + " 0" + " L " + SideLength + " " + SideLength + " L 0 " +SideLength + " z"); theSVGSquare.setAttributeNS(null,'style', 'fill:none; stroke:black; stroke-width:0.5px; shape-rendering:crispEdges'); // Add a translation to the element. This is how the square will be moved. // intialSquare defines the initial position for the square. var squareTranslate = svgArea.createSVGTransform(); squareTranslate.setTranslate(initialSquare.x,initialSquare.y); theSVGSquare.transform.baseVal.insertItemBefore(squareTranslate,0); // Add it to the DOM so that it's visible. svgArea.appendChild(theSVGSquare);
The snippet above is painfully arcane, considering
what it does; but on the positive side, there's no longer a need for a function
to draw the square, as there was when using canvas
.
The drawing happens automatically. For comparison, here's the code used with
canvas
to draw the square.
ctx.beginPath(); ctx.moveTo(x,y) ctx.lineTo(x+SideLength,y); ctx.lineTo(x+SideLength,y+SideLength); ctx.lineTo(x,y+SideLength); ctx.lineTo(x,y); ctx.stroke();
Above, x
and y
are the location of the square. The
canvas
code isn't really any shorter than what's used for
svg
, but it is easier to understand.
The next difference compared to using canvas
is the way
the coordinates of the mouse event are converted to coordinates relative to
the SVG viewBox
. Use a transformation matrix provided by SVG
to handle this.
var screenPt = svgArea.createSVGPoint(); screenPt.x = theEvent.clientX; screenPt.y = theEvent.clientY; var svgPt = screenPt.matrixTransform(svgArea.getScreenCTM().inverse());
Moving the square around on the screen is relatively easy. Call
squareTranslate.setTranslate(x,y)
to move the square
to the position (x,y)
, where x
and
y
are given relative to the viewBox
coordinates.
Instead of having the entire SVG drawing area listen for mouse events and figure out what to do with them, it's possible to have the SVG element – which is now part of the DOM – listen for the events. The square then handles it's own events.
To change to this type of event handling, a few changes are necessary, most of which make the code shorter. The most relevant change is that
svgArea.addEventListener("mousedown",doMouseDown,false);
becomes
theSVGSquare.addEventListener("mousedown",doMouseDown,false);
The other changes to the code basically amount to removing unnecessary plumbing. Since the square is directly listening for events, when it receives an event, it definitely belongs to the square, and can't be an event in the surrounding area. There are two more significant changes.
By default, the "square," which is really a closed path,
will receive only events on the path that forms the perimeter;
it won't receive events inside the square since that's not part of the path. Without
any further changes, you would need to click on the thin perimeter to move the square.
If the square is filled, by setting the style
to something like
fill:green
, then the interior will receive events too. But we want to
use fill:none
. We need a new style
setting:
pointer-events: all
. There are other pointer-events
settings too, depending on how you want to treat different parts of the drawing.
One problem with having the square listen for mouse moves is that, if you move the mouse
faster than the event-handler code can keep up, the mouse can "escape." That is,
it gets outside of the square, and even though the mouse button is down,
doMouseMove
stops hearing about the mouse location. To fix that, the
SVG drawing area is still used as the event listener for mouse moves.
There's no way to totally fix the jittery lines widths. It arises for several reasons, and to explain what's happening requires a deeper dive into what the browser means by things like "pixel."
First, there's the good old pixel that we've had for decades. It's the smallest "dot" the monitor can produce. For some time, it was standard on older desktop machines to expect 72ppi – before roughly 2000. Then, for a few years, 96ppi was the norm on the desktop. Today, the pixel density can range well into hundreds per inch. I'll call these physical, device, screen or monitor pixels. They are what the user actually sees, and what any software ultimately manipulates.
Next comes the "CSS pixel," which is sometimes referred to as a "reference pixel."
This unit is typically what the px
suffix means in HTML or JavaScript.
By definition, there are 96 CSS pixels per inch. When designing, it's useful to know
that a certain number of CSS pixels will translate to a known length on the
screen – a length which you could measure with a ruler and get an expected value.
The problem with CSS pixels is that they are an abstraction which may not match up
nicely with the actual pixel density of the screen. For example, according to the manufacturor's
website, the monitor I am using right now has a resolution of 102.42ppi. The ratio
of CSS pixels to screen pixels could be almost any value (within a certain range).
Now the first cause of the jittery lines can be explained. A mousemove
event is reported whenever the mouse moves at all, even by a single device pixel.
The location of the mouse is reported in several ways. There's clientX
and clientY
, which are given in CSS pixels, and there's
screenX
and screenY
, which are reported in device pixels.
If you zoom in and observe what's reported for these values, with
console.log()
, you'll see that the mouse can move by a device pixel,
yet the value reported in CSS pixels remains unchanged. Moreover, the values
given by clientX
and clientY
are whole numbers!
This is ridiculous – CSS pixels are not small physical squares at integer
locations; they're a short-cut to make layout easier and are an inherently
floating-point concept. Maybe the standard for event locations
will change to floating-point values eventually, and maybe it has already
changed in some browsers, but in the browsers I've tested it's a whole number.
If there were a way to convert the screen pixels given with a mouse event to SVG units, then we might be on the road to a solution, but that's not possible. There doesn't seem to be any way to obtain the screen coordinates of an element of the DOM. Without that, knowing the screen coordinates of the event is useless.
The ultimate cause for the jittery lines is the way that SVG coordinates, expressed
relative to viewBox
, are converted to device pixels. SVG is an inherently
floating-point world – indeed, that is SVG's attraction – but SVG objects
are viewed in the integer world of pixels. Exactly how one is translated to the
other matters. Suppose there's a black "block" at certain SVG coordinates, and that this
block is exactly the size of a device pixel once it's been mapped to the screen. If the
block maps to a location on the screen that straddles two device pixels, then you may end up
with two pixels filled rather than one. To prevent SVG objects from jittering, they
need to be placed at SVG coordinates so that the edges of any lines map to the edges
of device pixels.
In principle, it's possible to work backwards from device pixels to SVG units so
that things line up nicely. The problem is that, to do this, we need information
that the browser doesn't make available. We need to know the ratio of device pixel
density to CSS pixels exactly, and the ratio given by
window.devicePixelRatio
won't cut it. Moreover, every time the user
changes the zoom level, the location of the SVG elements would need to be nudged
slightly.
There's no reasaonble way to prevent the square from being jittery. That said, at some zoom levels, it won't jitter because the zoom level happens, by mere coincidence, to line up nicely with the dimensions of the lines. In particular, in the examples I've looked at, if the browser is zoomed in as far as possible, then there's no jitter.
Depending on what's being drawn, if you can use thick lines, then any jitter won't be so noticable. If the line is already thick, then a tiny difference isn't so obvious. Also, these jitters are most obvious with horizontal and vertical lines. Lines at an angle still jitter, but it's not as jarring to the eyes.
There is one additional downside to using SVG: it's absolutely hoggish in terms of memory.
I don't have any exact figures, in bytes, but theSVGSquare
belong to the
DOM, and therefore carries a monstrous amount of baggage. Once there are more
than a limited number of elements, it will be slow.
Speaking as someone who is new to working within the browser framework, it's
remarkable that there appears to be no reasonable way to draw things to the screen with
precision. The ability to draw a line between two points without strange artifacts
seems like the absolute minimum to expect. You can't do it with canvas
or with SVG.