September 30, 2013
Drag & drop with SVG
For the patience project, I had to fight some performance problems. Displaying a playing card as SVG grafic is in itself a completely harmless task for a browser. But as soon as you try to drag the card with the mouse across others, or when cards are displayed one on top of the other, as in a pile, delays get obvious; and the game flow is impeded.
🔗 Cards as individual SVG files
The D3 framework uses functions of data objects to create and manipulate DOM elements.
This is not the place to describe the process in detail, a short introduction can be viewed
here . In a first attempt, I bound objects
containing the src
url to <img>
elements:
{
key: // unique key for pile, card index and rendered card
ref: "cards/card_(...).svg" // URL of the SVG file for the card
}
For every pile of cards these objects a collected in an array card_names
.
First, the constructor for the class creating the gamepad defines a dynamic stylesheet to be able to adjust the rendered card sizes:
function Area(...) {
...
var sheet = d3.select("head").append("style").property("sheet");
sheet.insertRule("img {}", 0); // insert a dynamic style sheet for cards
this.rule = sheet.cssRules[0];
...
}
Depending on the browser window real estate a scaling factor is determined:
Area.prototype.resize = function () {
...
this.scale = ... // compute scaling factor
this.card_w = Math.round(101 * this.scale); // actual card sizes
this.card_h = Math.round(156 * this.scale);
this.rule.style.width = this.card_w + "px"; // write sizes into the stylesheet
this.rule.style.height = this.card_h + "px";
...
};
When assigning cards to their piles, the <img>
elements are added to the pile and
for newly appended cards the url link is retrieved and set from the data object:
Area.prototype.alter_pile = function (pile) {
...
var stack = this.get_stack(pile); // DOM element of pile
var card_names = to_view(pile); // assign d(ata) objekt for every card
var gd = stack.inner.selectAll("img").data(card_names, function (d) {
return d.key; // identifies pre-existing cards by their unique id
});
gd.enter().append("img");
gd.attr("src", function(d) {
return d.ref;
});
...
};
The result is disappointing for all tested browsers - current versions of Chrome, Firefox and Opera, IE 9 and IE10. Cards being dragged with the mouse will move with a noticable delay. It is as if they "hop" out of their original position only after some time lag. Dropped onto another pile it takes several tenths of a second before they are rendered and the game gets ready for user interaction again.
🔗 Fallback: high resolution PNG files
The published variant exchanges the SVG files for PNG files with a relatively high resolution (200 × 308 pixel). That results in the considerable size of 1.3 MB for the totality of the cards, but the resulting load times can be alleviated for the most part by leveraging the application cache.
This is the sole solution that consistently enables fluent playing on all browsers. Nonetheless, it is contrived.
So, how do you get the scalability of SVGs to the client without wasting computing time for permanently repainting objects that in essence remain static? I have tested two scenarios:
🔗 Copy sprites to canvas elements
For this attempt the cards are gathered in a single SVG file. At initialization, it is loaded
in an HTMLImageElement outside of the DOM tree. From there clippings containing just one card
are rendered onto canvas
elements.
The data object contains no longer an URL but the coordinates describing where in the sprite an individual card can be found:
{
key: // unique key for pile, card index and rendered card
ref_x: card.value - 1, // column index of the card in Karten.svg
ref_y: base.suit_names.indexOf(card.suit), // row index
}
Since the sizing must be given as element attributes, the card size cannot be set via a dynamic stylesheet:
function Area(...) {
...
this.karten = new Image(); // offscreen image
d3.select(this.karten).on("load", function() {
...
});
this.karten.src = "cards/Karten.svg"; // file contains all cards in a grid
}
Area.prototype.resize = function () {
...
this.scale = ... // compute scaling factor
this.card_w = Math.round(101 * this.scale); // actual card sizes
this.card_h = Math.round(156 * this.scale);
... // draw all existing cards again
};
Area.prototype.alter_pile = function (pile) {
...
var stack = this.get_stack(pile); // DOM element of pile
var card_names = to_view(pile); // assign d(ata) objekt for every card
var gd = stack.inner.selectAll("canvas").data(card_names, function (d) {
return d.key; // identifies pre-existing cards by their unique id
});
var self = this;
gd.enter().append("canvas")
.attr("width", this.card_w).attr("height", this.card_h) // assign actual card size
.each(function (d) {
var ctx = this.getContext('2d'); // this references the current canvas element
ctx.clearRect(0, 0, self.card_w, self.card_h); // clear and draw again
ctx.drawImage(self.karten, d.ref_x * 101, d.ref_y * 156, 101, 156, 0, 0,
self.card_w, self.card_h);
});
...
};
This solution works (almost) flawlessly with Chrome. Gameplay is fluent and the card are rendered
in reasonable times on scaling. Unfortunately one error muddies the water: <svg:pattern>
fills
refuse to scale and are rendered blurred on magnification.
Internet Explorer renders the cards error-free, rendering times are suboptimal, but can be tolerated.
Firefox fails to scale the clippings and shows the cards always in their base resolution of 101 × 156 pixel.
Opera fails deplorably. neither images are rendered offscreen, nor it seems
canvas.drawImage()
is implemented.
🔗 SVG as background image
This attempt relies on CSS. Cards are displayed as background images in <div>
elements.
Consequently the data object has URL in functional notation:
{
key: // unique key for pile, card index and rendered card
ref: "url(cards/card_(...).svg)" // funcURL of the SVG file for the card
}
Es kommt wieder ein dynamisches Stylesheet zum Einsatz, beim Aktualisieren der Stapel muss nur das Hintergrundbild ausgetauscht werden:
function Area(...) {
...
var sheet = d3.select("head").append("style").property("sheet");
sheet.insertRule("div.bgcard {}", 0); // insert a dynamic style sheet for cards
this.rule = sheet.cssRules[0];
...
}
Area.prototype.resize = function() {
...
this.scale = ... // compute scaling factor
this.card_w = Math.round(101 * this.scale); // actual card sizes
this.card_h = Math.round(156 * this.scale);
this.rule.style.width = this.card_w + "px"; // write sizes into the stylesheet
this.rule.style.height = this.card_h + "px";
this.rule.style.backgroundSize = this.card_w + "px";
...
};
Area.prototype.alter_pile = function(pile) {
...
var stack = this.get_stack(pile); // DOM element of pile
var card_names = to_view(pile); // assign d(ata) objekt for every card
var gd = stack.inner.selectAll("div").data(card_names, function (d) {
return d.key; // identifies pre-existing cards by their unique id
});
var self = this;
gd.enter().append("div").classed("bgcard", true)
.style("background-image", function (d) {
return d.ref;
});
};
Again Chrome ranks best and shows everything fluently and correct.
Internet Explorer requires Version 10, in Version 9 some CSS3 features are missing.
Firefox has a devastating performance. On loading a game the maximum execution time for scripts was surpassed on multiple occasions.
Opera produces multiple errors: Images aren't re-rendered on scaling, and on rendering card images for the first time it is possible they won't show on the screen at all until the whole screen buffer is written again.