Des graphiques simples en SVG

Comment afficher quelques statistiques sur une page Web sans JavaScript

Utilisant Matomo pour avoir des statistiques (complètement anonymisées) de visites sur mon site/blog, j’avais envie d’afficher quelques graphiques et infos sur mon tableau de bord sans avoir besoin de visiter l’interface Matomo.

Récupérer les statistiques

Cette partie est vraiment propre à Laravel et Matomo, vous pouvez vous en inspirer… ou complètement l’ignorer si vous avez un socle technique différent

J’ai utilisé le package Laravel Piwik pour récupérer les informations dont j’avais besoin, puis créé des graphiques en SVG manuellement, via un composant Laravel dédié.

La récupération des infos est une simple boucle pas optimisée qui utilise l’API Matomo pour obtenir un résumé des visites des 7 derniers jours :

<?php

use Illuminate\View\Component;
use Illuminate\Support\Carbon;
use RobBrazier\Piwik\Facades\Piwik;

class AnalyticsWidget extends Component
{
	public string $date = 'today';
	public string $period = 'day';

	public array $visits_labels = [];
	public array $visits_values = [];
	public array $time_labels = [];
	public array $time_values = [];
	public ?string $loading_error = null;

	public function __construct()
	{
		$day = new Carbon();
		$visits_labels = [];
		$visits_values = [];
		$time_labels = [];
		$time_values = [];

		try {
			for ($i = 0; $i <= 7; $i++) {
				$stats = Piwik::getVisitsSummary()->get(['period' => $this->period, 'date' => $day->toDateString()]);
				$stats->date = $day->copy();

				$visits_labels[$i] = $stats->date->format('d/m');
				$visits_values[$i] = $stats->nb_visits;

				$time_labels[$i] = $stats->date->format('d/m');
				$time_values[$i] = $stats->avg_time_on_site;

				$day = $day->subDay();
			}

			// On inverse l'ordre des données pour retrouver un ordre chronologique
			$this->visits_labels = array_reverse($visits_labels);
			$this->visits_values = array_reverse($visits_values);
			$this->time_labels = array_reverse($time_labels);
			$this->time_values = array_reverse($time_values);
		} catch (ClientException $error) {
			$this->loading_error = 'Erreur client : '.$error->getMessage();
		} catch (ServerException $error) {
			$this->loading_error = 'Le serveur a rencontré une erreur ('.$error->getResponse()->getStatusCode().' '. $error->getResponse()->getReasonPhrase().')';
		} catch (RequestException $error) {
			$this->loading_error = 'La requête HTTP a échoué';
		}
	}
}
app/View/Components/Admin/Dashboard/AnalyticsWidget.php

Je peux alors injecter les données à mon composant qui va créer le graphique :

@if(!empty($loading_error))
<div class="widget-content-padding">
	<div class="alert color-error">
		<p><strong>Erreur de chargement</strong></p>
		<p>{{ $loading_error }}</p>
	</div>
</div>
@else
<x-admin.chart :title="__('Visites sur le site')" :labels="$visits_labels" :data-points="$visits_values" left-label="Visites"></x-admin.chart>
<x-admin.chart :title="__('Temps passé sur le site')" :labels="$time_labels" :data-points="$time_values" left-label="Temps"></x-admin.chart>
@endif
resources/views/components/admin/dashboard/analytics-widget.blade.php

Préparer le graphique

Avant de générer le SVG il y a quelques calculs à faire pour s’assurer qu’il ait une tête convenable.

<?php

namespace App\View\Components\Admin;

use Illuminate\View\Component;

class Chart extends Component
{
	public float $minValue = 0;
	public float $maxValue = 1;
	public array $yValues = [];

	public int $width = 1000;
	public int $height = 500;
	public float $gutter = 20;
	public float $pointSize = 13;
	public float $pointStrokeSize = 4;
	public float $lineSize = 3;

	public int $leftLabelsNb = 4;

	public float $leftLabelsWidth = 100;
	public float $leftLabelHeight;
	public float $xLabelsHeight = 40;
	public float $xLabelWidth;

	public float $graphWidth;
	public float $graphHeight;

	/**
	 * Create a new component instance.
	 *
	 * @return void
	 */
	public function __construct(public string $title = '', public array $labels = [], public array $dataPoints = [], public string $bottomLabel ='', public string $leftLabel = '')
	{
		$this->labels = $labels;
		$this->dataPoints = $dataPoints;

		if (count($this->dataPoints)) {
			$this->maxValue = max($this->dataPoints);

			for ($i = $this->maxValue; $i >= $this->minValue; $i -= ($this->maxValue - $this->minValue) / ($this->leftLabelsNb - 1)) {
				$this->yValues[] = round($i, 2);
			}
		}

		$this->graphWidth = $this->width - (3 * $this->gutter) - $this->leftLabelsWidth;
		$this->graphHeight = $this->height - (3 * $this->gutter) - $this->xLabelsHeight;

		$this->leftLabelHeight = $this->graphHeight / ($this->leftLabelsNb - 1);
		$this->xLabelWidth = $this->graphWidth / count($this->labels);
	}

	/**
	 * Get the view / contents that represent the component.
	 *
	 * @return \Illuminate\Contracts\View\View|\Closure|string
	 */
	public function render()
	{
		return view('components.admin.chart');
	}
}
app/View/Components/Admin/Chart.php

On commence donc par calculer la valeur maximale (on pourrait aussi calculer la valeur la plus basse pour supprimer l’espace en bas, si l’écart de valeurs est plus important que les valeurs elles-mêmes) et donc définir l’échelle sur l’axe des ordonnées.

On calcule aussi la largeur des intitulés sur l’axe des abscisses pour les placer correctement.

J’utilise aussi une "gouttière" ($gutter) tout autour du composant, ainsi qu’entre l’axe des ordonnées et le graphique lui-même.

Certaines valeurs sont fixes pour assurer une cohérence entre les graphiques, mais peuvent être rendues paramétrables facilement en les définissant dans le constructeur.

Construction du SVG

Le code est plutôt simple, c’est du SVG assez basique et quelques balises Blade (système de template utilisé par Laravel) pour boucler et injecter les valeurs.

<div class="chart">
    <svg version="1.2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 {{ $width }} {{ $height }}" class="line-graph" role="img">
        @if(!empty($title))
        <title>{{ $title }}</title>
        @endif

        <g class="grid x-grid" id="xGrid">
            <line x1="{{ (2 * $gutter) + $leftLabelsWidth }}" x2="{{ (2 * $gutter) + $leftLabelsWidth }}" y1="{{ $gutter }}" y2="{{ $gutter + $graphHeight }}"></line>
        </g>
        <g class="grid y-grid" id="yGrid">
            <line x1="{{ (2 * $gutter) + $leftLabelsWidth }}" x2="{{ $width - $gutter }}" y1="{{ $gutter + $graphHeight }}" y2="{{ $gutter + $graphHeight }}"></line>
        </g>

        <rect x="{{ (2 * $gutter) + $leftLabelsWidth }}" y="{{ $gutter }}" width="{{ $graphWidth }}" height="{{ $graphHeight }}" class="data-background"></rect>

        <g class="labels x-labels">
            @foreach ($labels as $i => $value)
            <text x="{{ ($i * $xLabelWidth) + ($xLabelWidth / 2) + (2 * $gutter) + $leftLabelsWidth }}" y="{{ $height - $gutter - ($xLabelsHeight / 2) }}" font-size="{{ $xLabelsHeight * 0.7 }}" text-anchor="middle">{{ $value }}</text>
            @endforeach

            @if(!empty($bottomLabel))
            <text x="{{ (2 * $gutter) + ($graphWidth / 2) }}" y="{{ $height - $gutter }}" font-size="{{ $xLabelsHeight * 0.7 }}" class="label-title" text-anchor="middle">{{ $bottomLabel }}</text>
            @endif
        </g>

        <g class="labels y-labels">
            @foreach($yValues as $i => $value)
            <text x="{{ $gutter + $leftLabelsWidth }}" y="{{ $gutter + ($i * $leftLabelHeight) }}" font-size="{{ $xLabelsHeight * 0.7 }}" text-anchor="end">{{ $value }}</text>
			<line x1="{{ (2 * $gutter) + $leftLabelsWidth }}" y1="{{ $gutter + ($i * $leftLabelHeight) }}" x2="{{ (2 * $gutter) + $leftLabelsWidth + $graphWidth }}" y2="{{ $gutter + ($i * $leftLabelHeight) }}" />
            @endforeach

            @if(!empty($leftLabel))
            <text x="{{ $gutter }}" y="{{ $gutter + ($graphHeight / 2) }}" font-size="{{ $xLabelsHeight * 0.7 }}" class="label-title" text-anchor="start">{{ $leftLabel }}</text>
            @endif
        </g>

        <g class="data">
            <polyline fill="none" stroke-width="{{ $lineSize }}" points="
                @foreach ($dataPoints as $i => $value)
                {{ (2 * $gutter) + $leftLabelsWidth + ($i * $xLabelWidth) + ($xLabelWidth / 2) }},{{ $gutter + $graphHeight - ($graphHeight * (($value - $minValue) / ($maxValue - $minValue))) }}
                @endforeach" />
            @foreach ($dataPoints as $i => $value)
            <circle cx="{{ (2 * $gutter) + $leftLabelsWidth + ($i * $xLabelWidth) + ($xLabelWidth / 2) }}" cy="{{ $gutter + $graphHeight - ($graphHeight * (($value - $minValue) / ($maxValue - $minValue))) }}" data-value="{{ $value }}" r="{{ $pointSize }}" stroke-width="{{ $pointStrokeSize }}"></circle>
            @endforeach
        </g>
    </svg>
</div>
resources/views/components/admin/chart.blade.php

On commence donc par placer un rectangle pour le fond du graphique (histoire de décorer) puis on construit les échelles : l’axe horizontal d’abord puis l’axe vertical (même si l’ordre n’a pas vraiment d’importance).

Ensuite on boucle sur les données pour afficher une ligne, puis on recommence pour les points qui s’y superposent. On pourra leur donner un effet de contour pour cacher un bout de la ligne avec du CSS (et l’attribut stroke-width qui est déjà présent ici).

Un peu de CSS pour rendre ça joli

Le SVG est assez monochrome, alors on n’oublie pas d’ajouter quelques couleurs pour coller au reste du site.

.chart {
    display: block;
    position: relative;
    width: 100%;
    margin: size(2) auto;

    svg {
        display: block;
        width: 100%;
        overflow: visible;
    }

    .grid {
        stroke: color('gray', 500); // La fonction `color` est déclarée en SCSS pour utiliser un dégradé facilement
        stroke-dasharray: 0;
        stroke-width: 1px;
    }

    .labels {
        text {
            fill: color('gray', 600);
            stroke-width: 0;
            transform: translateY(.5em);
        }
        line {
            stroke: color('gray', 200);
            stroke-width: 1px;
        }
    }

    .label-title {
        stroke: color('gray', 500);
        font-weight: 500;
    }

    .data-background {
        fill: color('gray', 50);
    }
    .data {
        circle {
            fill: color('blue', 500);
            stroke: color('gray', 50);

            &:hover {
                fill: color('blue', 700);
                filter: drop-shadow(0 0 .5rem color('gray', 300));
            }
        }
        polyline {
            stroke: color('blue', 200);
        }
    }
}

Rien de bien compliqué ici, il sagit surtout de rendre le SVG responsive et d’appliquer quelques couleurs. Détail important pour donner le bon effet aux points qui cachent la ligne : on définit stroke (pour le sélecteur .data circle) de la même couleur que la propriété fill du rectangle .data-background.


Et voilà le résultat, avec le widget complet :

Aperçu du graphique SVG dans le widget de statistiques sur le tableau de bord du site
Aperçu du graphique SVG dans le widget de statistiques sur le tableau de bord du site

Belle montagne, n’est-ce pas ? 😎

3 commentaires

Intéressant.

Bien que ce que tu as fait est écrit en PHP, il ne doit pas être trop compliqué à adapter à d’autres langages et framework.

J’ai utilisé un moment Matomo pour un de mes sites. Mais vu l’utilité que j’en avais, j’ai préféré migrer vers Plausible, qui à mon sens, est encore plus respectueux des utilisateurs (vie privée, etc.).

+1 -0

(il manque https:// dans ton lien d’ailleurs)

viki53

Fixed!

J’ai un compte Matomo payant (flemme de l’héberger) du coup je vais pas changer comme ça, mais je note de regarder ça plus en profondeur :)

viki53

Ah oui, ça ce comprend. Comme je l’auto-héberge, je fait attention au coût que cela représente (monétaire, ressources machines, etc.)

Après, Matomo ou Plausible, PHP ou Python… le plus important ici c’est le résultat du SVG ^^

viki53

Tout a fait, ce qui compte, c’est la data qu’on en ressort, le résultat :)

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte