Sunday, March 18, 2018

City Symbols (Part 16): Custom Icons and External SVG

Another feature I want to support is the ability to use custom icons, e.g., as drawn by a human artist. Here's an example using some simple icons I created in Inkscape:
This works much the same way as the custom texture patterns for the ocean and land.  The custom icons are put into an HTML <img> tag to force the browser to load the image and then an SVG <image> is created which references the same url.  This SVG <image> is then put onto the map.

The advantage of this approach is that it works for any image format the browser supports.  However, there are also several disadvantages.  If I use any pixel-based image format (such as JPEG or PNG) then the image will degrade as the map is zoomed.  On the other hand, if I use a vector-based image format (such as SVG) then zoom will work fine, but I have no access to the actual SVG of the image.  Why is that important?  Well, in a case like the city icons, it would be nice if I could (for example) go into the SVG of the capital city icon and re-color the star so to match the territory color.  (So Shimpes above would have have a green star, for example.)

To do this requires solving a couple of problems.  First, I need a way to load an external SVG file so that I have access to the DOM when the SVG is inserted into the page.  Second, I then need to be able to walk through the DOM and adjust colors (etc.) to fit the map.

As far as loading the external SVG goes, d3 provides a function d3.xml that will help.  XML -- the eXtensible Markup Language -- is a language for creating data files that are easily machine-readable, and SVG is written in XML.  So d3.xml can be used to read and parse SVG files, and actually returns a document fragment that is suitable for injecting into a web page.

Of course, there are a few wrinkles to getting this working.  One is that I currently run Dragons Abound as a standalone Javascript application directly from the file system.  (It loads in the browser as a file:/// URL.)   It turns out that using XMLHttpRequest from a file URL is a little problematic.  There's something in modern browsers called the same-origin policy.   The same-origin policy is intended to keep Javascript from one web page executing Javascript from another web page, since this could be used to do things like steal your PIN number out of your banking web page.  However, same-origin policy is only well-defined for protocols like HTTP and HTTPS.  And in particular, it has never been defined for file URLs.  As a result, browsers can do whatever they want with these URLs.  And they do!  It turns out that Chrome simply forbids any file URL to load any other file URL.  (Whether this is reasonable is a separate debate.)

There are couple of ways to work around this.  The first would be to switch Dragons Abound to run off an HTTP URL.  Technically, this is the best solution.  It would make accessing external resources simple.  The drawback is that it would require me to run a web server on my development machine.  That's not a huge problem -- there are plenty of small, lightweight web servers I could use -- but it's a little bit annoying to have to bring up a web server when I want to work on the program.

Another solution is to start Chrome with the "--allow-file-access-from-files" switch.  This switch is described as "By default, file:// URIs cannot read other file:// URIs. This is an override for developers who need the old behavior for testing."  In other words, exactly what I'm doing.  So that's my solution, at least for now.

Another wrinkle has to do with the contents of the SVG files.  If you open up an SVG file created by something like Inkscape, you find that it's actually a complete SVG:

<svg [...] sodipodi:docname="icon00.svg">
    <defs  id="defs2" />
    <sodipodi:namedview
     units="px" />
    <metadata
     id="metadata5">
        <rdf:RDF>
            <cc:Work
         rdf:about="">
                <dc:format>image/svg+xml</dc:format>
                <dc:type
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
                <dc:title></dc:title>
            </cc:Work>
        </rdf:RDF>
    </metadata>
    <g
     inkscape:label="Layer 1"
     inkscape:groupmode="layer"
     id="layer1"
     transform="translate(0.64906156,-291.10098)">
        <circle
       style="opacity:1;fill:none;"
       id="path4506"
       cx="2.6249902"
       cy="294.37503"
       r="2.4633491" />
        <path
       style="opacity:1;fill:#000000;"
       id="path833"
       d="m 4.9755297,296.6547 [...]" />
    </g>
</svg>

All I really care about in here is that <g> element about halfway down, or maybe only the elements inside that group.  So I could just pull those out.  But I might also need something in the <defs> element, and there are other complications -- this is actually a very simple example.

Happily, it turns out that SVG has no problem including another SVG.  This whole element can just be pulled out and dropped into the map SVG and everything works exactly as we'd like it to.  So I just have to pull the SVG element out of the document fragment returned by d3.xml:

let svgNode = documentFragment.getElementsByTagName("svg")[0];

And then I'm good to go.  Well, almost.  XMLHttpRequest is actually asynchronous, which means I have to kick it off and let it go do the business of loading the external file, and take care not to use it until that has finished.  (Dragons Abound takes long enough to generate the world that this isn't a problem.)   This Stackoverflow answer outlines the basic method for using d3.xml and a callback to read an SVG file, and Mike Bostock (the d3 author) provides a working example here.

Now that I have the SVG file loaded so that I have access to it's structure, I want to customize the icon.  In this case, I want to recolor parts of the icon to match the color of the surrounding territory. Recoloring an SVG element is easy -- I just need to set the 'fill' style to the new color.  The trick here is figuring out which elements within the file I just loaded I should recolor.  I need some way to "mark" elements for recoloring when I create them in Inkscape (or some other way).

One straightforward way to do this is to add an XML attribute to the objects to be recolored, for example, adding a 'recolor' element.  SVG files are text files, so you could do this by opening the icons up in a text editor and manually adding the attribute.  But Inkscape also has an XML Editor that lets you directly edit the XML representation of any object, so it's also easy to add a custom element that way.  Then you can recolor all the marked elements in one fell swoop with something like this:

city.icon.selectAll("[recolor]").style('fill', Color.makeColor(atc));


(Assuming you're using D3 and you've marked elements with a "recolor" attribute.)  Throwing all that in the program gives this:
And voila! the inside of the star icons have been recolored to match the territory color.

This is of limited use for city icons, but the same approach can fix a problem I've had with land and sea patterns.  When these patterns are treated as images, they cannot be recolored.  So to make the patterns work with any color of sea or land, I've made them a partially transparent gray color.  This has the effect of darkening the underlying color, as can be seen in the land hatching in these examples:
But as you can see, this not only darkens the color (changes the luminance) but it also changes the shade of the color (changes the hue) making it grayer.  Recoloring the pattern with a color made from the base color of the land fixes this.
In the example on the right, the land hatching is no longer gray but a darker land color.  It also makes it possible to change the darkness or color programatically.  (Rather than having to create a new pattern image file.)  I'm not sure why I'd want to do that, but I guess it's now an option...

No comments:

Post a Comment