Skip to content

Instantly share code, notes, and snippets.

@thomaswilburn
Last active January 16, 2024 03:33
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thomaswilburn/f5d58f0ce415b76086c2a4f397c8f810 to your computer and use it in GitHub Desktop.
Save thomaswilburn/f5d58f0ce415b76086c2a4f397c8f810 to your computer and use it in GitHub Desktop.
Creating a document minimap with SVG

One of my favorite no-so-secret weapons for data visualization on the web is the SVG viewBox attribute. I've written about it before: in my textbook, I have a whole chapter on art direction with viewBox, and I also talk about it in the section on rendering SVG from JS.

Essentially, the viewBox attribute lets you set up the... view box. SVG works on a 2d coordinate system that can (but doesn't have to) match the CSS positioning grid for an element. You can use the attribute to tell the SVG to set an offset for the rendering canvas, to zoom in or out, or to crop the image to keep an area in view while still fitting in an arbitrary aspect ratio (similar to how object-fit and object-position work in CSS, but more granular).

But on a recent project, I also had a chance to use viewBox as a literal view box: for a Votebeat story on ballot readability, we generated cropped comparison images of ballots to show the relative size of specific page sections, with a little minimap to give the full context. Instead of pre-rendering the graphics, or using JavaScript to generate the thumbnails, we did it all in SVG.

Here's how it works. In our template code, we define a data structure for a crop and any number of highlighted areas (we use EJS templating, so the data structure is basically a JSON object that our template can apply):

{
  title: "Pinal",
  image: "./assets/pinal-crop.jpg",
  height: 1776,
  viewBox: [0, 0, 800, 600],
  highlights: [
    [32, 90, 736, 210]
  ]
}

The viewBox and highlights are both just x/y/width/height quads, the way we would define any SVG rectangle. So rendering the crop is pretty easy. Here's the EJS template:

<svg
  class="main"
  viewBox="<%= config.viewBox.join(" ") %>"
>
  <image href="<%= config.image %>" x="0" y="0" width="800" height="<%= config.height %>" />
  <% for (var [x,y,w,h] of config.highlights) { %>
  <rect
    class="highlight"
    x="<%= x %>"
    y="<%= y %>"
    width="<%= w %>"
    height="<%= h %>"
    vector-effect="non-scaling-stroke"
  />
  <% } %>
</svg>

And the resulting HTML:

<svg class="main" viewBox="0 0 800 600">
  <image href="./assets/pinal-crop.jpg" x="0" y="0" width="800" height="1776"></image>
  <rect class="highlight" x="32" y="90" width="736" height="210" vector-effect="non-scaling-stroke"></rect>
</svg>

(Unfortunately, GitHub doesn't render SVG inline in Markdown files. But you can drop this code into an HTMl file to try it.)

Since the viewBox will act as the width/height of the SVG in the absence of explicit attributes or styles, this has the effect of creating a cropped image in the right aspect ratio to fit that viewBox pretty much exactly. If we wanted to tweak that, we could change the preserveAspectRatio, but depending on its value, we'd have to deal with a possible mismatch with the minimap view.

Now, to generate the minimap, we use the same data, and pretty much the same template. The only difference is that this time, we render the view box as a rectangle, and set the viewBox of the minimap SVG to show the whole page:

<svg viewBox="0 0 800 1600"
  width="800" height="1600" 
  preserveAspectRatio="xMidYMid slice">
  <image href="<%= config.image %>" width="800" height="<%= config.height %>" />
  <rect
    <% var [x, y, w, h] = config.viewBox; %>
    x="<%= x %>"
    y="<%= y %>"
    width="<%= w %>"
    height="<%= h %>"
    class="locator"
    vector-effect="non-scaling-stroke"
  />
  <g class="highlights">
    <% for (var [x,y,w,h] of config.highlights) { %>
    <rect
      class="highlight"
      x="<%= x %>"
      y="<%= y %>"
      width="<%= w %>"
      height="<%= h %>"
      vector-effect="non-scaling-stroke"
    />
    <% } %>
  </g>
</svg>

Resulting in:

<svg viewBox="0 0 800 1600" width="800" height="1600" preserveAspectRatio="xMidYMid slice">
  <image href="./assets/pinal-crop.jpg" width="800" height="1776"></image>
  <rect x="0" y="0" width="800" height="600" class="locator" vector-effect="non-scaling-stroke"></rect>
  <g class="highlights">
    <rect class="highlight" x="32" y="90" width="736" height="210" vector-effect="non-scaling-stroke"></rect>
  </g>
</svg>

The .locator rectangle is our view box, visualized in gray. Since our highlights are still in the same coordinate system (we've just zoomed out from the crop by setting the viewBox attribute to full width), we don't need to reposition them at all--they'll show up inside the locator, looking pretty much just like the cropped version does. Using CSS, we can make this SVG smaller and place it in one of the lower corners of the original image so that readers can orient themselves.

To me, this captures the tremendous power of learning SVG basics. Sure, you could do something similar in JavaScript, or draw it yourself in Illustrator or Inkscape. But leveraging viewBox means that we can create the same effect in only a few bytes, without having to work out the positioning/scaling math ourselves. It's all just different views of the same scaled, Cartesian grid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment