Browser…​unplugged
Showcases of modern Browser technologies

30. September 2013

Drag & Drop mit SVG

Für das Patience-Projekt hatte ich mit einigen Performance-Problemen zu kämpfen. Eine Spielkarte als SVG-Grafik im Browser darzustellen ist ansich völlig harmlos. Aber wenn die Karte mit der Maus über andere hinweg gezogen werden soll, oder für einen Kartenstapel viele übereinander dargestellt werden, werden Verzögerungen offensichtlich, die den Spielablauf behindern.

🔗 Karten als individuelle SVG-Dateien

Das D3-Framework verwendet Funktionen von Datenobjekten, um DOM-Elemente zu erzeugen und zu manipulieren. Hier ist nicht der Platz, das Verfahren im Detail zu beschreiben, eine kurze Einführung steht hier . In meinem ersten Ansatz habe ich <img>-Elementen Objekte zugeordnet, die die src-URL enthalten:

{
    key: // eindeutiger Schlüssel für Stapel, Kartenindex und dargestellte Karte
    ref: "cards/card_(...).svg" // URL der svg-Datei für die Karte
}

Für jeden Kartenstapel werden diese Objekte in einem Array card_names zusammengefasst.

In einem Konstruktor für die Klasse, die das Spielfeld erzeugt, wird zunächst ein dynamisches Stylesheet angelegt, um die Größe der dargestellten Karten anpassen zu können:

function Area(...) {
    ...
    var sheet = d3.select("head").append("style").property("sheet");
    sheet.insertRule("img {}", 0); // erzeuge dynamisches Stylesheet für Karten
    this.rule = sheet.cssRules[0];
    ...
}

Je nach dem im Browserfenster zur Verfügung stehenden Platz wird ein Skalierungsfaktor ermittelt:


Area.prototype.resize = function () {
    ...
    this.scale = ... // berechne Skalierungsfaktor
    this.card_w = Math.round(101 * this.scale); // aktuelle Kartengrößen
    this.card_h = Math.round(156 * this.scale);
    this.rule.style.width = this.card_w + "px"; // schreibe Größen in das Stylesheet
    this.rule.style.height = this.card_h + "px";
    ...
};

Bei der Zuordnung von Karten zu Kartenstapeln werden die erforderlichen <img>-Elemente in den Stapel eingefügt und für neu hinzugefügte Karten der URL-Verweis aus dem Datenobjekt gesetzt:

Area.prototype.alter_pile = function (pile) {
    ...
    var stack = this.get_stack(pile); // DOM-Element des Stapels
    var card_names = to_view(pile); // erzeuge d(ata)-Objekt für jede Karte
    var gd = stack.inner.selectAll("img").data(card_names, function (d) {
        return d.key; // identifiziert schon existente Karten nach ihrem Schlüssel
    });
    gd.enter().append("img");
    gd.attr("src", function(d) {
        return d.ref;
    });
    ...
};

Das Ergebnis ist für alle getesteten Browser (aktuelle Versionen von Chrome, Firefox und Opera, IE 9 und 10) enttäuschend: Werden Karten mit der Maus gezogen, gibt es eine erkennbare Verzögerung, bis sie aus ihrer Ausgangsposition „herausspringen“. Bei Ablage auf einem anderen Stapel dauert es bis zu mehreren Zehntelsekunden, bis sie dort dargestellt werden und das Spiel wieder auf Benutzeraktionen reagiert.

🔗 Rückfallposition: Hochaufgelöste PNG-Dateien

Die veröffentlichte Variante ersetzt die SVG-Dateien durch relativ hoch aufgelöste PNGs (200 × 308 Pixel). Zwar ergibt das für den vollständigen Kartensatz stolze 1,3 MB, aber durch Nutzung des Applikationscaches können Ladezeiten weitgehend minimiert werden.

Dies ist die einzige Möglichkeit, konsistent auf allen Browsern ein flüssiges Spielen zu ermöglichen. Trotzdem, es ist und bleibt eine Behelfslösung.

Wie lässt sich also die Skalierbarkeit von SVGs auf den lokalen Rechner bringen, ohne durch ständiges Neuzeichnen von Objekten, die eigentlich statisch bleiben, unnötige Rechenzeiten zu vergeuden? Ich habe zwei weitere Varianten getestet.

🔗 Bildausschnitte in canvas-Elemente kopieren

Bei diesem Ansatz sind die Karten in einer SVG-Datei zusammengefasst, die bei Initialisierung in einem HTMLImageElement außerhalb des DOM-Baumes gerendert wird. Zur Darstellung werden dann Ausschnitte mit jeweils einer Karte in canvas-Elemente kopiert.

Das Datenobjekt enthält dafür keine URL mehr, sondern Koordinaten, die beschreiben, wo in dem Kartenbild die individuelle Karte zu finden ist:

{
    key: // eindeutiger Schlüssel für Stapel, Kartenindex und dargestellte Karte
    ref_x: card.value - 1, // Index der Spalte für die Karte in Karten.svg
    ref_y: base.suit_names.indexOf(card.suit), // Index der Zeile
}

Da die Größenangaben als direkte Attribute erforderlich sind, kann die Kartengröße nicht über ein dynamisches Stylesheet global gesetzt werden:

function Area(...) {
    ...
    this.karten = new Image(); // Offscreen-Image
    d3.select(this.karten).on("load", function() {
        ...
    });
    this.karten.src = "cards/Karten.svg"; // Datei enthält alle Kartenbilder in einem Raster
}

Area.prototype.resize = function () {
    ...
    this.scale = ...                            // berechne Skalierungsfaktor
    this.card_w = Math.round(101 * this.scale); // aktuelle Kartengrößen
    this.card_h = Math.round(156 * this.scale);
    ... // zeichne alle bestehenden Karten neu
};

Area.prototype.alter_pile = function (pile) {
    ...
    var stack = this.get_stack(pile); // DOM-Element des Stapels
    var card_names = to_view(pile);   // erzeuge d(ata)-Objekt für jede Karte
    var gd = stack.inner.selectAll("canvas").data(card_names, function (d) {
        return d.key; // identifiziert schon existente Karten nach ihrem Schlüssel
    });
    var self = this;
    gd.enter().append("canvas")
        .attr("width", this.card_w).attr("height", this.card_h) // aktuelle Kartengröße zuweisen
        .each(function (d) {
            var ctx = this.getContext('2d'); // this verweist auf das aktuelle canvas-Element
            ctx.clearRect(0, 0, self.card_w, self.card_h);  // leeren und neu zeichnen
            ctx.drawImage(self.karten, d.ref_x * 101, d.ref_y * 156, 101, 156, 0, 0,
                self.card_w, self.card_h);
        });
    ...
};
Screenshot
In Chrome verwischen svg-Pattern bei Darstellung mit canvas

Diese Lösung funktioniert mit Chrome (fast) einwandfrei, das Spielerlebnis ist flüssig und die Kartendarstellung skaliert mit guten Rendering-Zeiten. Leider trübt ein Fehler das Bild: <svg:pattern>-Füllelemente verweigern sich der Skalierung und werden als verwaschene Pixel dargestellt.

Der Internet Explorer stellt die Karten fehlerfrei dar, die Rendering-Zeiten sind suboptimal, aber noch einigermaßen erträglich.

Screenshot
Unskalierte Übernahme aus dem SVG führt in Firefox zu verwischter Darstellung im canvas

Firefox beherrscht die Skalierung der erzeugten Bildausschnitte nicht und zeigt die Karten immer nur in der Basisauflösung 101 × 156 Pixel.

Opera scheitert kläglich. Weder kann er Bilder offscreen rendern, noch scheint es, dass canvas.drawImage() implementiert ist.

🔗 SVG als Hintergrundbild

Diese Lösung setzt auf CSS. Die Karten werden als

-Elemente mit einem Hintergrundbild dargestellt. Dementsprechend enthält das Datenobjekt die URL in funktionaler Schreibweise:

{
    key: // eindeutiger Schlüssel für Stapel, Kartenindex und dargestellte Karte
    ref: "url(cards/card_(...).svg)" // funcURL der svg-Datei für die Karte
}

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); //erzeuge dynamisches Stylesheet für Karten
    this.rule = sheet.cssRules[0];
    ...
}

Area.prototype.resize = function() {
    ...
    this.scale = ...                            // berechne Skalierungsfaktor
    this.card_w = Math.round(101 * this.scale); // aktuelle Kartengrößen
    this.card_h = Math.round(156 * this.scale);
    this.rule.style.width = this.card_w + "px"; // schreibe Größen in das 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 des Stapels
    var card_names = to_view(pile);   // erzeuge d(ata)-Objekt für jede Karte
    var gd = stack.inner.selectAll("div").data(card_names, function (d) {
        return d.key; // identifiziert schon existente Karten nach ihrem Schlüssel
    });
    var self = this;
    gd.enter().append("div").classed("bgcard", true)
        .style("background-image", function (d) {
            return d.ref;
        });
};
Screenshot
In Opera unterbleibt teilweise die Auffrischung des Bildschirmpuffers, so dass scheinbar Karten fehlen

Wieder zeigt sich, dass Chrome am besten abschneidet und alles flüssig und korrekt darstellt.

Internet Explorer erfordert hier Version 10, in Version 9 fehlen noch einige CSS3-Funktionen.

Firefox bietet eine geradezu unterirdische Performance, beim Laden von Spielen wurden bei mir mehrfach die maximalen Scriptausführungszeiten überschritten.

Opera erzeugt mehrere Fehler: Die Bilder werden bei Skalierung nicht neu neu gerendert, und bei der Neuerzeugung von Kartenbildern kann es sein, dass sie auf dem Bildschirm gar nicht erscheinen, bis der Fensterpuffer insgesamt neu geschrieben wird.