Browse Source

Graphique des visites en d3.js.

J'ai essayé de séparer une partie "graph" (plus générale) d'une partie "statistiques" plus particulière à ce graphique particulier, mais ce n'est pas évident.

Le fichier prive/stats/visites charge les JS et CSS de d3 et pour notre graphique.
Il crée alors de graphique des visites. Pour cela, la fonction `spip_d3_statistiques_create(id)` va
- créer un objet Spip_d3_graph() notamment en indiquant la locale désirée. Cet objet est stocké dans la balise #id par un jQuery .data() pour pouvoir le réutiliser ensuite (pour ré-actualiser le graphique avec d'autres données)
- déclarer comment seront préparées et traitées les données JSON reçues (ici on remplit les dates manquantes, et on actualise le tableau html et le graphique svg)
- localiser les dates de d3 avec la locale demandé
- préparer le code html et svg nécessaire (appelle la fonction spip_d3_statistiques_prepare_graph() qui va préparer les axes, histogramme, ligne, etc, mais sans connaître encore les données)
- executer graph.updateJson(), qui va télécharger le json de données, et appliquer dessus les traitements déclarés, ce qui va in fine exécuter spip_d3_statistiques_update_graph() qui dessinera le graphique)

Une navigation au dessus du graphique permet de basculer sur différentes dates et collections de données : 3 mois (90 jours), 2 ans (730 jours), 5 ans (60 mois) et infini (en années). Jusqu'à 2 ans, les données retournées par SPIP sont quotidiennes. En demandant 5 ans, on obtient 60 mois (avec la somme cumulée des visites sur le premier jour du mois) ; en demandant infini (durée -1), on obtient des visites annuelles : ce choix n'est pas anodin car il correspond très exactement à ce qui est proposé en archivage des statistiques : les statistiques de plus de 2 ans sont regroupées en début de mois, et de plus de 5 ans en début d'année. De la sorte, il devrait être possible de mettre une tache périodique qui archive systématiquement les statistiques, pour alléger la base de données, tout en gardant un affichage des graphiques a peu près corrects, même si on perd une partie de l'information.
pull/4/head
Matthieu Marcillaud 2 years ago
parent
commit
c280b9485b
  1. 194
      css/spip_d3_graph.css
  2. 14
      css/spip_d3_statistiques.css
  3. 200
      javascript/spip_d3_graph.js
  4. 301
      javascript/spip_d3_statistiques.js
  5. 148
      prive/squelettes/inclure/stats-visites-jours.html
  6. 35
      prive/squelettes/inclure/stats-visites-jours_fonctions.php
  7. 186
      prive/stats/visites.html
  8. 67
      prive/style_prive_plugin_stats.html

194
css/spip_d3_graph.css

@ -0,0 +1,194 @@
/*
.line {
fill: none;
stroke: steelblue;
stroke-width: 2px;
}*/
.spip_d3_graph {
--spip-d3--theme-color--light: var(--spip-color-theme-light, #999);
--spip-d3--theme-color: var(--spip-color-theme, #666);
--spip-d3--theme-color--dark: var(--spip-color-theme-dark, #444);
--spip-d3--text-color: #444;
--spip-d3--border-color: #ddd;
--spip-d3--background-color: #fafafa;
--spip-d3--loader-color: var(--spip-d3--theme-color);
--spip-d3--loader-color--loading: var(--spip-d3--theme-color--dark);
--spip-d3--line-color: var(--spip-d3--theme-color);
--spip-d3--line-color--primary: var(--spip-d3--theme-color);
--spip-d3--line-color--secondary: hsl(calc(var(--spip-color-theme--h) + 120), var(--spip-color-theme--s), var(--spip-color-theme--l));
--spip-d3--line-color--tertiary: hsl(calc(var(--spip-color-theme--h) + 240), var(--spip-color-theme--s), var(--spip-color-theme--l));
--spip-d3--bar-color: var(--spip-d3--theme-color--light);
--spip-d3--bar-color--hover: var(--spip-d3--theme-color);
--spip-d3--area-color--low: #fff;
--spip-d3--area-color--high: var(--spip-d3--theme-color);
--spip-d3--grid-color: #ddd;
}
.spip_d3_graph {
display: grid;
place-items: center;
border: 1px solid var(--spip-d3--border-color);
background-color: var(--spip-d3--background-color);
margin-bottom: 3em;
}
.spip_d3_graph > * {
grid-area: 1/1;
}
/** ratio */
.spip_d3_graph_ratio {}
/** loader */
.spip_d3_graph_loader {
color: var(--spip-d3--loader-color);
transition: width .3s, height .3s, color .3s;
width: 60px;
height: 60px;
display: none;
z-index: 1000;
}
/** when loading */
.spip_d3_graph--loading .spip_d3_graph_loader {
display: block;
}
/** graphique */
.spip_d3_graph_inner {
width: 100%;
position: relative; /** for tooltip */
}
/* ===================== */
.spip_d3_svg {}
.spip_d3_svg_title {
text-anchor: middle;
fill: var(--spip-d3--text-color);
}
.spip_d3_svg_grid line {
stroke: var(--spip-d3--grid-color);
shape-rendering: crispEdges;
stroke-opacity: 0.7;
}
.spip_d3_svg_grid path {
stroke-width: 0;
}
.spip_d3_svg_grid--horizontal line {
stroke-opacity: 0.7;
}
.spip_d3_svg_grid--vertical line {
stroke-opacity: 0.5;
}
.spip_d3_svg_area {
opacity: 0.5;
}
.spip_d3_svg_line {
fill: none;
stroke: var(--spip-d3--line-color);
stroke-width: 2;
stroke-miterLimit: 1;
}
.spip_d3_svg_line--average {
stroke: var(--spip-d3--line-color--secondary);
}
.spip_d3_svg_histogram {}
.spip_d3_svg_bar {
fill: var(--spip-d3--bar-color);
transition: fill .3s;
}
.spip_d3_svg_bar--hover,
.spip_d3_svg_bar:hover {
fill: var(--spip-d3--bar-color--hover);
}
.spip_d3_svg_overlay {
fill: none;
pointer-events: all;
}
/* ===================== */
.spip_d3_tooltip {
position: absolute;
opacity: 0;
border-radius: 5px;
background-color: var(--spip-color-theme-lightest);
border: 1px solid var(--spip-color-theme);
}
.spip_d3_tooltip_list {
list-style-type: none;
margin: 0;
padding: .5em;
}
.spip_d3_tooltip_label {
font-weight: bold;
color: var(--spip-color-theme-darker);
padding-right: .5em;
}
.spip_d3_tooltip_value {
}
/* ====================== */
.spip_d3_table {
margin-bottom: 2em;
}
/** tables responsives */
@media (max-width: 760px) {
.spip_table--responsive,
.spip_table--responsive thead,
.spip_table--responsive tbody,
.spip_table--responsive th,
.spip_table--responsive td,
.spip_table--responsive tr {
display: block;
}
.spip_table--responsive thead tr {
border: none;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.spip_table--responsive tr {
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
.spip_table--responsive tr + tr {
margin-top: 1em;
}
.spip_table--responsive td {
text-align: right;
}
table.spip_table--responsive td {
border-top: none;
border-bottom: 1px solid rgba(0, 0, 0, .08);
}
.spip_table--responsive td:before {
float: left;
font-weight: bold;
content: attr(data-label);
}
}

14
css/spip_d3_statistiques.css

@ -0,0 +1,14 @@
.spip_d3_statistiques {
width: 100%;
min-height: 400px;
padding: 1em;
box-sizing: border-box;
}
.spip_d3_statistiques--visites_quotidiennes {}
table.spip.spip_table--statistiques {
margin-bottom: 3em;
}

200
javascript/spip_d3_graph.js

@ -0,0 +1,200 @@
class Spip_d3_graph {
/**
* @param string id Identifiant sélecteur du conteneur
* @param {*} options Options
* - locale : locale, tel que "fr-FR"
* - language : (à défaut de locale) language, tel que "fr"
* - d3_directory :chemin vers le répertoire de d3...
*/
constructor(id, options = {}) {
this.id = id;
this.container = d3.select(this.id);
this.inner = this.container.select('.spip_d3_graph_inner');
d3.spip = d3.spip || {};
d3.spip.locale = d3.spip.locale || {
default: "en_EN",
loaded: "en_EN",
desired: "",
data: {},
}
if (typeof options.locale !== 'undefined') {
this.set_desired_locale(options.locale);
} else if (typeof options.language !== 'undefined') {
this.set_desired_locale(options.language.toLowerCase() + '-' + options.language.toUpperCase());
}
this.d3_directory = options.d3_directory || "";
}
set_desired_locale(locale) {
locale = locale.replace('_', '-');
d3.spip.locale.desired = locale;
}
/**
* Localise les dates
*
* @param {*} data Data à retourner ensuite
* @param {*} desired_locale Locale désirée (sinon utilise celle déjà demandée via constructeur ou set_desired_locale)
* @returns
*/
async localize_d3_time(data = {}, desired_locale = null) {
// bazar pour avoir la localisation avant de tracer le graphique...
// il faut le faire en asynchrone...
if (desired_locale !== null) {
this.set_desired_locale(desired_locale);
}
const locale = d3.spip.locale;
if (locale.desired && locale.loaded !== locale.desired) {
if (locale.desired in locale.data) {
d3.timeFormatDefaultLocale(locale.data[locale.desired]);
} else {
const localization_file = this.d3_directory + "/time-format/locale/" + locale.desired + ".json";
await d3
.json(localization_file)
.then(definition => {
d3.spip.locale.loaded = locale.desired;
d3.spip.locale.data[locale.desired] = definition;
return d3.timeFormatDefaultLocale(definition)
})
.catch(error => console.warn("d3 language file for " + locale.desired + " not loaded", error));
}
}
return data;
}
loading_start() {
this.container.node().classList.add("spip_d3_graph--loading");
}
loading_end() {
this.container.node().classList.remove("spip_d3_graph--loading");
}
set_dataLoader(callback) {
this.jsonLoaderCallback = callback;
}
updateJson() {
this.loading_start();
d3
.json(this.get_jsonUrl())
.then(this.jsonLoaderCallback)
.then(data => {
this.loading_end();
return data;
})
.catch(function(error) {
console.error(error);
this.loading_end();
this.error(error);
})
}
get_jsonUrl() {
return this.container.attr("data-json");
}
error(error) {
this.container.append("div").attr("class", "error").text(error);
}
nextDate(date, unit, add) {
const current = new Date(String(date));
if (unit === 'day') {
const next = new Date(current.setDate(current.getDate() + add));
return next.toLocaleDateString('en-CA');
} else if (unit === 'month') {
const next = new Date(current.setMonth(current.getMonth() + add));
return next.toLocaleDateString('en-CA').slice(0, 7);
} else if (unit === 'year') {
return current.getFullYear() + add;
} else {
throw "invalid unit in nextDate().";
}
}
prepare_columns(data) {
const columns = [];
for (const [key, value] of Object.entries(data.meta.columns)) {
columns.push({key: key, label: value});
}
return columns;
}
select_otherwise(element, tag, onempty) {
let selection = element.select(tag);
if (selection.empty()) {
if (typeof onempty === "function") {
selection = onempty(element);
}
}
return selection;
}
select_otherwise_append(element, tag, onappend) {
let selection = element.select(tag);
if (selection.empty()) {
selection = element.append(tag);
if (typeof onappend === "function") {
onappend(selection);
}
}
return selection;
}
update_table(data) {
if (this.inner.select('table').empty()) {
this.prepare_table();
}
const table = this.inner.select('table');
const caption = table.select('caption');
if (this.container.attr("data-title")) {
caption.text(this.container.attr("data-title")).style('display', 'table-caption');
} else {
caption.text("").style('display', 'none');
}
const columns = this.prepare_columns(data);
table.select('thead tr')
.selectAll('th')
.data(columns)
.join('th')
.text(column => column.label);
table.select('tbody')
.selectAll('tr')
.data(data.data)
.join('tr')
.selectAll('td')
.data(d => {
return columns.map(column => {
return { column: column, value: d[column.key] }
});
})
.join('td')
.text(d => d.value)
.attr("data-label", d => d.column.label);;
}
prepare_table(visible = false) {
const table = this.inner
.append('table')
.attr("class", "spip_d3_table spip_table--responsive");
if (!visible) {
table.style("display", "none");
}
table.append('caption');
table.append('thead').append('tr');
table.append('tbody');
return table;
}
}

301
javascript/spip_d3_statistiques.js

@ -0,0 +1,301 @@
/**
* Affiche le graphique ou le tableau
*
* @param node btn html du bouton
* @param string id identifiant du graphique
* @param string to 'svg' ou 'table'
*/
function spip_d3_statistiques_toggle_svg_table(btn, id, to) {
jQuery(btn).parent().find('.bouton').removeClass('principal');
jQuery(btn).addClass('principal');
if (to === 'table') {
jQuery(id).find('.spip_d3_svg').hide().end().find('.spip_d3_table').show();
} else {
jQuery(id).find('.spip_d3_table').hide().end().find('.spip_d3_svg').show();
}
const url = parametre_url(window.document.location.href, 'vue', to);
window.history.replaceState({}, window.document.title, url);
}
/**
* Recharge le graphique avec cette url json
*
* @param node btn html du bouton
* @param string $id identifiant du graphique
*/
function spip_d3_statistiques_load_json(btn, id) {
jQuery(btn).parent().find('.bouton').removeClass('principal');
jQuery(btn).addClass('principal');
const json = btn.dataset.json;
const json_auteur = btn.dataset.jsonAuteur;
const csv_auteur = btn.dataset.csvAuteur;
//const csv_auteur = parametre_url(json_auteur, 'page', 'statistiques.csv');
jQuery(btn).closest('.statistiques-nav').find('.btn--stats-json').attr('href', json_auteur);
jQuery(btn).closest('.statistiques-nav').find('.btn--stats-csv').attr('href', csv_auteur);
const url = parametre_url(window.document.location.href, 'graph', btn.dataset.graph);
window.history.replaceState({}, window.document.title, url);
document.querySelector(id).dataset.json = json;
const graph = jQuery(id).data('graph');
graph.updateJson();
}
function spip_d3_statistiques_prepare_graph(id, visible = true) {
const inner = d3.select(id).select('.spip_d3_graph_inner');
const dimensions = {
height: 400,
width: 800,
margin: {
top: 50,
right: 20,
bottom: 20,
left: 60
}
};
dimensions.inner = {
width: dimensions.width - dimensions.margin.left - dimensions.margin.right,
height: dimensions.height - dimensions.margin.top - dimensions.margin.bottom,
}
const modele = {}
modele.dimensions = dimensions;
modele.parseTime = d3.timeParse("%Y-%m-%d");
modele.dateFormat = d3.timeFormat("%d %B %Y");
const x = d3.scaleTime().range([0, dimensions.inner.width ]);
const y = d3.scaleLinear().range([dimensions.inner.height, 0]);
modele.x = x;
modele.y = y;
modele.xAxis = g => g
.attr("transform", "translate(0, " + dimensions.inner.height + ")")
.call(d3.axisBottom(x).ticks(dimensions.width / 80)/*.tickSizeOuter(0)*/)
modele.yAxis = g => g
.call(d3.axisLeft(y).ticks(dimensions.inner.height / 40));
modele.line = d3.line()
.curve(d3.curveBasis)
.x(d => modele.x(d.date))
.y(d => modele.y(d.moyenne_mobile))
// Create one bin per month, use an offset to include the first and last months
modele.histogram = d3.bin()
.value(d => d.date);
modele.rollingSum = (data, windowSize = 7) => {
const summed = data.map((d, i) => {
const start = Math.max(0, i - windowSize)
const end = i + 1;
//const sum = { ...d };
//sum.visites = Math.round(d3.sum(data.slice(start, end), d => d.visites) / (end - start));
d.moyenne_mobile = Math.round(d3.sum(data.slice(start, end), d => d.visites) / (end - start));
return d;
})
return summed;
}
const svg_outer = inner
.append('svg')
.attr("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`)
.attr("class", "spip_d3_svg");
if (!visible) {
svg_outer.style("display", "none");
}
const svg = svg_outer
.append("g")
.attr("class", "spip_d3_svg_inner")
.attr("transform", "translate(" + dimensions.margin.left + ", " + dimensions.margin.top + ")");
svg
.append("g")
.attr("class", "spip_d3_svg_grid spip_d3_svg_grid--horizontal")
.call(
d3.axisLeft(modele.y)
.ticks(dimensions.inner.height / 40)
.tickSize(-dimensions.inner.width)
.tickFormat("")
)
const tooltip = inner
.append("div")
.attr("class", "spip_d3_tooltip")
.style("opacity", 0);
tooltip
.append("ul")
.attr("class", "spip_d3_tooltip_list");
modele.tooltip = {
show: () => {
tooltip
.transition()
.duration(200)
.style("opacity", 1)
},
hide: () => {
tooltip
.transition()
.duration(200)
.style("opacity", 0)
},
empty: () => {
tooltip.select('ul').html("");
},
add: (key, label, value) => {
const item = tooltip.select('ul')
.append('li')
.attr("class", "spip_d3_tooltip_data spip_d3_tooltip_data--" + key);
item
.append("strong")
.attr("class", "spip_d3_tooltip_label")
.text(label + " :");
item
.append("span")
.attr("class", "spip_d3_tooltip_value")
.text(value);
},
update: (event) => {
const data = modele.data;
const xp = d3.pointer(event)[0];
const x0 = x.invert(xp);
const i = d3.bisector(d => d.date).left(data, x0);
d = data[i - 1];
const pos = inner.node().getBoundingClientRect();
tooltip.style("top", (event.clientY - pos.y - 30) + "px");
if (xp < dimensions.inner.width / 2) {
tooltip.style("right", "").style("left", (event.clientX - pos.x + 30) + "px");
} else {
tooltip.style("left", "").style("right", (pos.right - event.clientX + 30) + "px");
}
modele.tooltip.empty();
if (d) {
modele.tooltip.add('date', modele.meta.columns.date, d.label);
modele.tooltip.add('visites', modele.meta.columns.visites, d.visites);
modele.tooltip.add('moyenne', 'Moyenne mobile', d.moyenne_mobile);
} else {
modele.tooltip.add('date', modele.meta.columns.date, '?');
}
}
}
svg
.append("rect")
.attr("class", "spip_d3_svg_overlay")
.attr("width", modele.dimensions.inner.width)
.attr("height", modele.dimensions.inner.height)
.on("mouseover", modele.tooltip.show)
.on("mouseout", modele.tooltip.hide)
.on("mousemove", modele.tooltip.update);
svg
.append('g')
.attr("class", "spip_d3_svg_histogram")
.on("mouseover", modele.tooltip.show)
.on("mouseout", modele.tooltip.hide)
.on("mousemove", modele.tooltip.update);
svg
.append('path')
.attr("class", "spip_d3_svg_line spip_d3_svg_line--average");
svg
.append('g')
.attr("class", "spip_d3_svg_xaxis");
svg
.append('g')
.attr("class", "spip_d3_svg_yaxis")
svg.datum(modele);
return svg;
}
function spip_d3_statistiques_update_graph(id, _data) {
if (d3.select(id).select('.spip_d3_svg_inner').empty()) {
spip_d3_statistiques_prepare_graph(id);
}
const svg = d3.select(id).select('.spip_d3_svg_inner');
const modele = svg.datum();
const x = modele.x;
const y = modele.y;
const data = _data.data;
const meta = _data.meta;
modele.data = data; // pour tooltip
modele.meta = meta; // pour tooltip
// format the data
data.forEach(d => {
d.label = d.date;
d.date = new Date(String(d.date));
d.visites = +d.visites;
});
modele.rollingSum(data);
x.domain(d3.extent(data, d => d.date));
modele.histogram.domain(x.domain())
if (meta.unite === 'day') {
modele.histogram.thresholds(x.ticks(d3.timeDay));
} else if (meta.unite === 'month') {
modele.histogram.thresholds(x.ticks(d3.timeMonth));
} else if (meta.unite === 'year') {
modele.histogram.thresholds(x.ticks(d3.timeYear));
} else {
throw "meta.unite not in day|month|year";
}
// group the data for the bars
const bins = modele.histogram(data);
// format the sum
bins.forEach((d, i) => {
d.visites = +0;
d.moyenne_mobile = +0;
if (d.length) {
for (let j = 0; j < d.length; j++) {
d.visites += d[j].visites;
d.moyenne_mobile += d[j].moyenne_mobile;
}
}
d.label = d[0].label;
d.date = d[0].date;
bins[i] = d;
});
y.domain([0, d3.max(bins, d => d.visites)]);
svg
.select('.spip_d3_svg_histogram')
.selectAll("rect")
.data(bins)
.join('rect')
.attr("class", "spip_d3_svg_bar spip_d3_svg_bar--primary")
.attr("x", 1)
.attr("transform", d => "translate(" + x(d.x0) + "," + y(d.visites) + ")")
.attr("width", d => x(d.x1) - x(d.x0) - .5)
.attr("height", d => modele.dimensions.inner.height - y(d.visites))
svg
.select('.spip_d3_svg_line')
.datum(data)
.attr("d", modele.line);
svg.select('.spip_d3_svg_xaxis').call(modele.xAxis);
svg.select('.spip_d3_svg_yaxis').call(modele.yAxis);
}

148
prive/squelettes/inclure/stats-visites-jours.html

@ -1,96 +1,60 @@
#SET{c,#VAL{article}|classement_populaires}
#SET{duree,#ENV{duree,0}|duree_affiche{jour}}
<div class="pagination">
<span class="dl">
#SET{args,#ARRAY{id_article,#ID_ARTICLE,duree,#GET{duree}}}
<a href="[(#URL_PAGE{transmettre,[(#VAL{statistiques}|param_low_sec{#GET{args}, '', 'transmettre'})]})]" class="noajax"><:statistiques:csv:></a>
</span>
|
[<span class="duree">(#GET{duree}) <:info_jours:></span>]
[(#ENV{duree,0}|non)<a href="[(#SELF|parametre_url{duree,-1})]" title="<:lien_tout_afficher:>">+</a>]
|
<a href="#" class="noajax" onclick="jQuery('.statistiques_visites_quotidiennes .visites').show(); jQuery('.statistiques_visites_quotidiennes .graphResult-wrap, .statistiques_visites_quotidiennes .graphInfo').hide(); return false;"><:statistiques:info_tableaux:></a> |
<a href="#" class="noajax" onclick="jQuery('.statistiques_visites_quotidiennes .visites').hide(); jQuery('.statistiques_visites_quotidiennes .graphResult-wrap, .statistiques_visites_quotidiennes .graphInfo').show(); return false;"><:statistiques:info_graphiques:></a>
</div>
#SET{max,0}
#SET{moy,0}
#SET{last,0}
#SET{lastlast,0}
<B_statsj>
<table class='spip info visites' style="width:145px;position:absolute;#LANG_RIGHT:0px;margin-top:10px;">
<caption><:statistiques:resume:></caption>
<tbody>
<tr class="odd on">
<th><:info_maximum|trim{':'}|trim|ucfirst:></th>
<td class='num'>[(#GET{max}|number_format{0,"","&nbsp;"})]</td>
</tr>
<tr class="odd on">
<th><:info_moyenne|trim{':'}|trim|ucfirst:></th>
<td class='num'>[(#GET{moy}|round|number_format{0,"","&nbsp;"})]</td>
</tr>
<tr class="even">
<th><a href="#URL_ECRIRE{stats_referers,jour=jour}"
title="<:statistiques:titre_liens_entrants|trim{':'}|trim|attribut_html:>: <:info_aujourdhui|trim{':'}|trim|attribut_html:>"><:info_aujourdhui|trim{':'}|trim|ucfirst:></a></th>
<td class='num'>[(#GET{last}|number_format{0,"","&nbsp;"})]</td>
</tr>
<tr class="even">
<th><a href="#URL_ECRIRE{stats_referers,jour=veille}"
title="<:statistiques:titre_liens_entrants|trim{':'}|trim|attribut_html:>: <:info_hier|trim{':'}|trim|attribut_html:>"><:info_hier|trim{':'}|trim|ucfirst:></a></th>
<td class='num'>[(#GET{lastlast}|number_format{0,"","&nbsp;"})]</td>
</tr>
<BOUCLE_art(ARTICLES){id_article}{statut?}>
<tr class="odd">
<th><:statistiques:info_popularite_5|trim{':'}|trim|ucfirst:></th>
<td class='num'>[(#CHAMP_SQL{popularite}|round|number_format{0,"","&nbsp;"})]</td>
</tr>
<tr class="odd">
<th><:info_total|trim{':'}|trim|ucfirst:></th>
<td class='num'>[(#VISITES|round|number_format{0,"","&nbsp;"})]</td>
</tr>
<tr class="odd">
<th></th>
<td class='num'>[<strong>(#ID_ARTICLE|array_search{#GET{c}}|plus{1})</strong>[(#GET{c}|count|singulier_ou_pluriel{info_classement_1,info_classement_2,liste})]]</td>
</tr>
</BOUCLE_art>
<tr class="odd">
<th><:info_total|trim{':'}|trim|ucfirst:></th>
<td class='num'>[(#REM|stats_total|number_format{0,"","&nbsp;"})]</td>
</tr>
<tr class="odd">
<th><:statistiques:info_popularite_2|trim{':'}|trim|ucfirst:></th>
<td class='num'>[(#CONFIG{popularite_total}|round|number_format{0,"","&nbsp;"})]</td>
</tr>
<//B_art>
</tbody>
</table>
<table class='visites' id='visites_quotidiennes'>
<caption><:statistiques:visites_journalieres:></caption>
<thead>
<tr class='row_first'>
<th><:date|trim{':'}|trim:></th>
<th class='valeur'><:statistiques:info_visites|trim{':'}|trim:></th>
<th class='moyenne'><:info_moyenne|trim{':'}|trim:></th>
<th class='prevision'><:statistiques:info_previsions|trim{':'}|trim:></th>
</tr>
</thead>
<tbody>
<BOUCLE_statsj(DATA){source stats_visites,jour,#GET{duree},#ENV{id_article}}>
<tr class="c_[(#CLE|affdate{l}|substr{0,3})][(#COMPTEUR_BOUCLE|=={#TOTAL_BOUCLE}|oui)c_today]">
<th title="[(#CLE|affdate{'Y/m/d'})]">[(#COMPTEUR_BOUCLE|=={#TOTAL_BOUCLE}|?{<:info_aujourdhui:>,[(#CLE|nom_jour) ][(#CLE|affdate_jourcourt)]})]</th>
<td class="val">#VALEUR{visites}</td>
<td class="mean">#VALEUR{moyenne}</td>
<td class="prev">#VALEUR{prevision}</td>
</tr>
#SET{max,#GET{max}|max{#VALEUR{visites}}}
#SET{moy,#VALEUR{moyenne}}
#SET{lastlast,#GET{last}}
#SET{last,#VALEUR{visites}}
</BOUCLE_statsj>
</tbody>
</table>
<div class="statistiques-nav">
<div class="groupe-boutons groupe-boutons--stats-graph">
<a class="bouton[ (#ENV{graph}|non|ou{#ENV{graph}|=={90-days}})principal]" href="#"
data-graph="90-days"
data-json="[(#VAL{json}|statistiques_url_data{jour,90})]"
data-json-auteur="[(#VAL{json}|statistiques_url_data_auteur{jour,90})]"
data-csv-auteur="[(#VAL{csv}|statistiques_url_data_auteur{jour,90})]"
onclick="spip_d3_statistiques_load_json(this, '#statistiques_visites'); return false;">
3 mois
</a>
<a class="bouton[ (#ENV{graph}|=={730-days}|oui)principal]" href="#"
data-graph="730-days"
data-json="[(#VAL{json}|statistiques_url_data{jour,730})]"
data-json-auteur="[(#VAL{json}|statistiques_url_data_auteur{jour,730})]"
data-csv-auteur="[(#VAL{csv}|statistiques_url_data_auteur{jour,730})]"
onclick="spip_d3_statistiques_load_json(this, '#statistiques_visites'); return false;">
2 ans
</a>
<a class="bouton[ (#ENV{graph}|=={60-months}|oui)principal]" href="#"
data-graph="60-months"
data-json="[(#VAL{json}|statistiques_url_data{mois,60})]"
data-json-auteur="[(#VAL{json}|statistiques_url_data_auteur{mois,60})]"
data-csv-auteur="[(#VAL{csv}|statistiques_url_data_auteur{mois,60})]"
onclick="spip_d3_statistiques_load_json(this, '#statistiques_visites'); return false;">
5 ans
</a>
<a class="bouton[ (#ENV{graph}|=={years}|oui)principal]" href="#"
data-graph="years"
data-json="[(#VAL{json}|statistiques_url_data{annee})]"
data-json-auteur="[(#VAL{json}|statistiques_url_data_auteur{annee})]"
data-csv-auteur="[(#VAL{csv}|statistiques_url_data_auteur{annee})]"
onclick="spip_d3_statistiques_load_json(this, '#statistiques_visites'); return false;">
</a>
</div>
<h3 class="caption--visites"><:statistiques:visites_journalieres:></h3>
<div class="groupe-boutons groupe-boutons--stats-vue">
<a class="bouton[ (#ENV{vue}|non|ou{#ENV{vue}|=={svg}})principal] noajax btn--stats-to-svg" href="#" onclick="spip_d3_statistiques_toggle_svg_table(this, '#statistiques_visites', 'svg'); return false;"><:statistiques:info_graphiques:></a>
<a class="bouton[ (#ENV{vue}|=={table}|oui)principal] noajax btn--stats-to-table" href="#" onclick="spip_d3_statistiques_toggle_svg_table(this, '#statistiques_visites', 'table'); return false;"><:statistiques:info_tableaux:></a>
<a class="bouton noajax btn--stats-csv" href="[(#VAL{csv}|statistiques_url_data_auteur{jour,90})]" target="_blank"><:statistiques:csv:></a>
<a class="bouton noajax btn--stats-json" href="[(#VAL{json}|statistiques_url_data_auteur{jour,90})]" target="_blank"><:statistiques:json:></a>
</div>
</div>
<div
id="statistiques_visites"
class="spip_d3_graph spip_d3_statistiques spip_d3_statistiques--visites spip_d3_graph--loading"
data-json="[(#VAL{json}|statistiques_url_data{jour,90})]"
data-title="<:statistiques:visites_journalieres|attribut_html:>"
>
<svg class="spip_d3_graph_ratio" viewBox="0 0 2 1"></svg>
[(#CHEMIN_IMAGE{graph_loader.svg}|balise_svg{Chargement, spip_d3_graph_loader})]
<div class="spip_d3_graph_inner"></div>
</div>
</B_statsj>

35
prive/squelettes/inclure/stats-visites-jours_fonctions.php

@ -5,3 +5,38 @@ if (!defined('_ECRIRE_INC_VERSION')) {
}
include_spip('prive/squelettes/inclure/stats-visites-data_fonctions');
/**
* L'url d'accès aux données json ou csv.
*
* Nécessite une autorisation avec l'auteur connecté sur l'url cible.
*/
function statistiques_url_data(array $Pile, string $type = 'json', string $unite = 'jour', ?int $duree = null) : string {
$url = generer_url_public("statistiques.$type");
$url = parametre_url($url, 'unite', $unite);
if ($duree and $duree > 0) {
$url = parametre_url($url, 'duree', $duree);
}
$objet = $Pile[0]['objet'] ?? null;
$id_objet = $Pile[0]['id_objet'] ?? null;
if ($objet) {
$url = parametre_url($url, 'objet', $objet);
}
if ($id_objet) {
$url = parametre_url($url, 'id_objet', $id_objet);
}
return $url;
}
/**
* L'url d'accès aux données json ou csv (pour cet auteur, même non connecté).
*
* Ajoute un hash pour un auteur donné, de sorte qu'il puisse accéder aux statistiques même non connecté
* Possiblement utilisé pour télécharger périodiquement ses statistiques depuis un cron
*/
function statistiques_url_data_auteur(array $Pile, string $type = 'json', string $unite = 'jour', ?int $duree = null) : string {
$url = statistiques_url_data($Pile, $type, $unite, $duree);
$params = param_low_sec('statistiques', ['hash' => md5($url)], '', 'voirstats');
// pas besoin de l'arg hash. Il sert juste à calculer une clé unique pour l'auteur.
return parametre_url($url . '&' . $params, 'hash', '');
}

186
prive/stats/visites.html

@ -1,130 +1,90 @@
<!--[if IE]>[<script type="text/javascript" src="(#CHEMIN{javascript/excanvas.js})"></script>]<![endif]-->
[<script type="text/javascript" src="(#CHEMIN{javascript/jquery.flot.js})"></script>]
[<script type="text/javascript" src="(#CHEMIN{javascript/jquery.flot.selection.js})"></script>]
[<script type="text/javascript" src="(#CHEMIN{javascript/jquery.flot.time.js})"></script>]
[<script type="text/javascript" src="(#CHEMIN{javascript/jquery.tflot.js})"></script>]
[<link rel="stylesheet" type="text/css" href="(#CHEMIN{css/spip_d3_graph.css})" />]
[<link rel="stylesheet" type="text/css" href="(#CHEMIN{css/spip_d3_statistiques.css})" />]
[<script type="text/javascript" src="(#CHEMIN{lib/d3/d3.min.js})"></script>]
[<script type="text/javascript" src="(#CHEMIN{javascript/spip_d3_graph.js})"></script>]
[<script type="text/javascript" src="(#CHEMIN{javascript/spip_d3_statistiques.js})"></script>]
<script type="text/javascript">
function trace_stats_table(table, classes, options) {
$table = $(table);
if ($table.is(':hidden')) {
return true; // pas a faire ou deja fait.
function spip_d3_statistiques_create(id, options = {}) {
if (jQuery(id).data('graph')) {
return;
}
const $nav = jQuery(id).parent().find('.statistiques-nav');
if ($nav.find('.groupe-boutons--stats-graph .bouton.principal').length) {
jQuery(id)[0].dataset.json = $nav.find('.groupe-boutons--stats-graph .bouton.principal').data('json');
}
const table_visible = !!$nav.find('.btn--stats-to-table.principal').length;
// copier le titre des tableaux
titre = $table.find("caption").text();
$table.siblings('.pagination').before("<h3 class='caption'>" + titre + "</h3>");
$table
.wrap("<div class='" + classes + "'></div>");
// mettre les visites avec un fond colore pour le graphique
$table.find("thead th:eq(1)").data({fill: true, serie: 'bar', color: '#FFD845',lineWidth:0});
$table.find("thead th:eq(2)").data({serie: 'line', color: '#7FC4FF'});
$table.find("thead th:eq(3)").data({fill: true, serie: 'bar', color: '#A9DD3A',lineWidth:0});
// mettre les previsions en premier
// (pour que les autres graph passent par dessus)
$table.find('thead tr th:first-child').after(function(){
return $(this).parent().find('th:eq(3)').detach();
});
$table.find('tbody tr th:first-child').after(function(){
return $(this).parent().find('td:last-child').detach();
const graph = new Spip_d3_graph(id, {
language: '#ENV{lang}',
d3_directory: '[(#CHEMIN{lib/d3/d3.min.js}|dirname)]',
});
jQuery(id).data('graph', graph);
graph.set_dataLoader(data => {
// ici on peuple les dates manquantes du json
return new Promise((resolve, reject) => {
function fillInDates(meta, data){
// put current data hash for efficient retrieval
// determine min/max of data
const currentDates = {};
const minDate = meta.start_date;
let currentDate = minDate;
const maxDate = meta.end_date;
params = {
legendeExterne:true,
legendeActions:true,
width:($('.large #page').length)?'755px':'560px', // 795px, 600px (sans le tableau de resume) ...
height:'250px',
modeDate:true,
zoom:true,
parse:{
axeOnTitle:true,
defaultSerie:{
bars:{show:true},
lines:{show:true},
points:{show:false}
}
},
flot:{
grid:{
axismargin:10
},
xaxis:{
labelWidth:45,
monthNames: [
'[(#VAL{2000-01-01}|nom_mois)]',
'[(#VAL{2000-02-01}|nom_mois)]',
'[(#VAL{2000-03-01}|nom_mois)]',
'[(#VAL{2000-04-01}|nom_mois)]',
'[(#VAL{2000-05-01}|nom_mois)]',
'[(#VAL{2000-06-01}|nom_mois)]',
'[(#VAL{2000-07-01}|nom_mois)]',
'[(#VAL{2000-08-01}|nom_mois)]',
'[(#VAL{2000-09-01}|nom_mois)]',
'[(#VAL{2000-10-01}|nom_mois)]',
'[(#VAL{2000-11-01}|nom_mois)]',
'[(#VAL{2000-12-01}|nom_mois)]'
]
},
yaxis:{
position: "right",
tickDecimals: 1,
tickFormatter: function nbFormatter(val, axis) {
if (val >= 1000000){
var fval = (val / 1000000).toFixed(axis.tickDecimals) ;
return fval.replace(/\.0$/,"") + " M";
}
else{
if (val >= 100000)
return (val / 1000).toFixed(axis.tickDecimals).replace(/\.0$/,"") + " k";
else{
var fval = val.toFixed(axis.tickDecimals).replace(/\.0$/,"") ;
return fval.replace(/(\d{3})$/," $1") ;
}
data.forEach(d => {
currentDates[d.date] = d;
});
// loop data and fill in missing dates
const filledInDates = [];
while (currentDate < maxDate) {
if (currentDates[currentDate]) {
filledInDates.push(currentDates[currentDate]);
} else {
filledInDates.push({"date": currentDate, "visites": 0});
}
currentDate = graph.nextDate(currentDate, meta.unite, 1);
}
return filledInDates;
}
},
infobulle:{show:true}
}
$table.tFlot($.extend(true, {}, params, options));
}
data.data = fillInDates(data.meta, data.data);
function trace_stats(){
trace_stats_table(
"#visites_quotidiennes",
"statistiques_visites_quotidiennes statistiques_visites",
{
grille:{weekend:true},
flot:{
xaxis:{
timeformat:"%d %b",
minTickSize: [1, "day"]
},
bars:{barWidth:24 * 60 * 60 * 1000}
}
resolve(data);
})
.then(data => {
graph.update_table(data);
spip_d3_statistiques_update_graph(id, data);
});
trace_stats_table(
"#visites_mensuelles",
"statistiques_visites_mensuelles statistiques_visites", {
grille:{years:true},
flot:{
xaxis:{
timeformat:"%b %y",
minTickSize: [1, "month"]
},
bars:{barWidth:30.4 * 24 * 60 * 60 * 1000 /* nb de jours... approximatif */}
}
});
graph.loading_start();
Promise.resolve()
.then((d) => {
// charger la locale de date avant de créer les axes… sinon ils ne sont pas traduits
return graph.localize_d3_time(d);
})
.then(() => {
graph.prepare_table(table_visible);
spip_d3_statistiques_prepare_graph(id, !table_visible);
graph.updateJson();
});
}
function spip_dessiner_statistiques(){
spip_d3_statistiques_create("#statistiques_visites");
}
jQuery(function($) {
trace_stats();
onAjaxLoad(trace_stats);
spip_dessiner_statistiques();
onAjaxLoad(spip_dessiner_statistiques);
});
</script>

67
prive/style_prive_plugin_stats.html

@ -17,58 +17,29 @@
#SET{left,#ENV{ltr}|choixsiegal{left,left,right}}
#SET{right,#ENV{ltr}|choixsiegal{left,right,left}}
.statistiques-nav {
display:flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #ccc;
padding: .25em .25em 0;
background-color: #f6f6f6;
margin-bottom: .5em;
}
ol.classement {list-style:decimal;margin:0;padding:0;padding-#GET{left}:50px;margin-bottom:1.5em;}
table.visites caption, h3.caption {font-size:1.3em;font-weight:bold;text-align: center;margin:0.5em auto; clear:both;}
.stats_visites #contenu h3.caption {float:#GET{left};margin:0.1em auto 0.5em;}
table.visites {width: 70%; margin-bottom: 1em; border-collapse: collapse; border-spacing: 0; line-height: normal;border:1px solid #999;}
table.visites a {color:#[(#GET{foncee}|couleur_foncer)];}
table.visites tr.row_first { background-color:#[(#GET{foncee}|couleur_foncer)];color:#fff;border: 1px solid #ddd; }
table.info.visites tr.row_first { background-color:#fff;color:#000;border: 1px solid #ddd; }
table.info.visites tr.odd td,table.info.visites tr.odd th { background-color:transparent; }
table.visites th, table.visites td { padding: 0.20em 0.40em; text-align: #GET{left}; border: 1px solid #ddd; }
table.visites th { vertical-align: bottom; font-weight: bold; }
table.visites.info th { vertical-align: top; }
table.visites tbody th { font-weight: normal; }
table.visites .on th { font-weight: bold; }
table.visites td { vertical-align: top; }
table.visites td.val, table.visites td.mean, table.visites td.cumul { text-align:center; }
table.visites tr.c_Sun td,
table.visites tr.c_Sun th {background-color:#[(#GET{claire}|couleur_eclaircir)];}
table.visites tr.c_today td,
table.visites tr.c_today th {background-color:#eee;}
/*
table#visites tr.c_recap td,
table#visites tr.c_recap th {background-color:#[(#GET{foncee}|couleur_foncer)];color:#fff;}
*/
/* graphique flot */
.graphique{clear:both; overflow:hidden; margin-bottom:2em;}
.graphResult-wrap {padding: 10px;float:#GET{left};}
.graphResult{}
.graphInfo{float:#GET{left}; clear:#GET{left};margin-#GET{left}:20px;}
.graphInfo td {padding: 5px;}
.graphInfo td.legendLabel { padding-#GET{left}:0; padding-#GET{right}:1em; vertical-align: top;}
.graphLegend{margin-top:0.5em; }
.graphVignette{}
.graphique .tickLabels {font-size: 11px !important;line-height: 14px;}
.graphLegend .legendColorBox div{width:14px; height:10px;}
.graphLegend .legendColorBox.cacher div div{position:absolute; left:-3000em;}
h3.caption--visites {
font-size:1.3em;
font-weight: bold;
text-align: center;
line-height: 1;
margin:0;
}
ol.classement {list-style:decimal;margin:0;padding:0;padding-#GET{left}:50px;margin-bottom:1.5em;}
.tooltip_statistiques{
position: absolute;
display: none;
border: 1px solid #fdd;
padding: 2px;
background-color: #fee;
}
.stats_referers .liste-items.referers li.referer {padding-left:150px;background-image: url("#CHEMIN_IMAGE{deplierhaut.gif}");background-position: 0.6925em 0.6925em;background-repeat: no-repeat;}
.stats_referers .liste-items.referers li.referer.open {background-image: url("#CHEMIN_IMAGE{deplierbas.gif}");}

Loading…
Cancel
Save