diff --git a/swh/web/assets/src/bundles/origin/visits-histogram.js b/swh/web/assets/src/bundles/origin/visits-histogram.js
index 44f82b49..604752e4 100644
--- a/swh/web/assets/src/bundles/origin/visits-histogram.js
+++ b/swh/web/assets/src/bundles/origin/visits-histogram.js
@@ -1,337 +1,337 @@
/**
* Copyright (C) 2018-2019 The Software Heritage developers
* See the AUTHORS file at the top-level directory of this distribution
* License: GNU Affero General Public License version 3, or any later version
* See top-level LICENSE file for more information
*/
// Creation of a stacked histogram with D3.js for software origin visits history
// Parameters description:
// - container: selector for the div that will contain the histogram
// - visitsData: raw swh origin visits data
// - currentYear: the visits year to display by default
// - yearClickCallback: callback when the user selects a year through the histogram
export async function createVisitsHistogram(container, visitsData, currentYear, yearClickCallback) {
const d3 = await import(/* webpackChunkName: "d3" */ 'utils/d3');
// remove previously created histogram and tooltip if any
d3.select(container).select('svg').remove();
d3.select('div.d3-tooltip').remove();
// histogram size and margins
let width = 1000;
- let height = 300;
+ let height = 200;
let margin = {top: 20, right: 80, bottom: 30, left: 50};
// create responsive svg
let svg = d3.select(container)
.attr('style',
'padding-bottom: ' + Math.ceil(height * 100 / width) + '%')
.append('svg')
.attr('viewBox', '0 0 ' + width + ' ' + height);
// create tooltip div
let tooltip = d3.select('body')
.append('div')
.attr('class', 'd3-tooltip')
.style('opacity', 0);
// update width and height without margins
width = width - margin.left - margin.right;
height = height - margin.top - margin.bottom;
// create main svg group element
let g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
// create x scale
let x = d3.scaleTime().rangeRound([0, width]);
// create y scale
let y = d3.scaleLinear().range([height, 0]);
// create ordinal colorscale mapping visit status
let colors = d3.scaleOrdinal()
.domain(['full', 'partial', 'failed', 'ongoing'])
.range(['#008000', '#edc344', '#ff0000', '#0000ff']);
// first swh crawls were made in 2015
let startYear = 2015;
// set latest display year as the current one
let now = new Date();
let endYear = now.getUTCFullYear() + 1;
let monthExtent = [new Date(Date.UTC(startYear, 0, 1)), new Date(Date.UTC(endYear, 0, 1))];
// create months bins based on setup extent
let monthBins = d3.timeMonths(d3.timeMonth.offset(monthExtent[0], -1), monthExtent[1]);
// create years bins based on setup extent
let yearBins = d3.timeYears(monthExtent[0], monthExtent[1]);
// set x scale domain
x.domain(d3.extent(monthBins));
// use D3 histogram layout to create a function that will bin the visits by month
let binByMonth = d3.histogram()
.value(d => d.date)
.domain(x.domain())
.thresholds(monthBins);
// use D3 nest function to group the visits by status
let visitsByStatus = d3.nest()
.key(d => d['status'])
.sortKeys(d3.ascending)
.entries(visitsData);
// prepare data in order to be able to stack visit statuses by month
let statuses = [];
let histData = [];
for (let i = 0; i < monthBins.length; ++i) {
histData[i] = {};
}
visitsByStatus.forEach(entry => {
statuses.push(entry.key);
let monthsData = binByMonth(entry.values);
for (let i = 0; i < monthsData.length; ++i) {
histData[i]['x0'] = monthsData[i]['x0'];
histData[i]['x1'] = monthsData[i]['x1'];
histData[i][entry.key] = monthsData[i];
}
});
// create function to stack visits statuses by month
let stacked = d3.stack()
.keys(statuses)
.value((d, key) => d[key].length);
// compute the maximum amount of visits by month
let yMax = d3.max(histData, d => {
let total = 0;
for (let i = 0; i < statuses.length; ++i) {
total += d[statuses[i]].length;
}
return total;
});
// set y scale domain
y.domain([0, yMax]);
// compute ticks values for the y axis
let step = 5;
let yTickValues = [];
for (let i = 0; i <= yMax / step; ++i) {
yTickValues.push(i * step);
}
if (yTickValues.length === 0) {
for (let i = 0; i <= yMax; ++i) {
yTickValues.push(i);
}
} else if (yMax % step !== 0) {
yTickValues.push(yMax);
}
// add histogram background grid
g.append('g')
.attr('class', 'grid')
.call(d3.axisLeft(y)
.tickValues(yTickValues)
.tickSize(-width)
.tickFormat(''));
// create one fill only rectangle by displayed year
// each rectangle will be made visible when hovering the mouse over a year range
// user will then be able to select a year by clicking in the rectangle
g.append('g')
.selectAll('rect')
.data(yearBins)
.enter().append('rect')
.attr('class', d => 'year' + d.getUTCFullYear())
.attr('fill', 'red')
.attr('fill-opacity', d => d.getUTCFullYear() === currentYear ? 0.3 : 0)
.attr('stroke', 'none')
.attr('x', d => {
let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return x(date);
})
.attr('y', 0)
.attr('height', height)
.attr('width', d => {
let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
let yearWidth = x(d3.timeYear.offset(date, 1)) - x(date);
return yearWidth;
})
// mouse event callbacks used to show rectangle years
// when hovering the mouse over the histograms
.on('mouseover', d => {
svg.selectAll('rect.year' + d.getUTCFullYear())
.attr('fill-opacity', 0.5);
})
.on('mouseout', d => {
svg.selectAll('rect.year' + d.getUTCFullYear())
.attr('fill-opacity', 0);
svg.selectAll('rect.year' + currentYear)
.attr('fill-opacity', 0.3);
})
// callback to select a year after a mouse click
// in a rectangle year
.on('click', d => {
svg.selectAll('rect.year' + currentYear)
.attr('fill-opacity', 0);
svg.selectAll('rect.yearoutline' + currentYear)
.attr('stroke', 'none');
currentYear = d.getUTCFullYear();
svg.selectAll('rect.year' + currentYear)
.attr('fill-opacity', 0.5);
svg.selectAll('rect.yearoutline' + currentYear)
.attr('stroke', 'black');
yearClickCallback(currentYear);
});
// create the stacked histogram of visits
g.append('g')
.selectAll('g')
.data(stacked(histData))
.enter().append('g')
.attr('fill', d => colors(d.key))
.selectAll('rect')
.data(d => d)
.enter().append('rect')
.attr('class', d => 'month' + d.data.x1.getMonth())
.attr('x', d => x(d.data.x0))
.attr('y', d => y(d[1]))
.attr('height', d => y(d[0]) - y(d[1]))
.attr('width', d => x(d.data.x1) - x(d.data.x0) - 1)
// mouse event callbacks used to show rectangle years
// but also to show tooltip when hovering the mouse
// over the histogram bars
.on('mouseover', d => {
svg.selectAll('rect.year' + d.data.x1.getUTCFullYear())
.attr('fill-opacity', 0.5);
tooltip.transition()
.duration(200)
.style('opacity', 1);
let ds = d.data.x1.toISOString().substr(0, 7).split('-');
let tooltipText = '' + ds[1] + ' / ' + ds[0] + ':
';
for (let i = 0; i < statuses.length; ++i) {
let visitStatus = statuses[i];
let nbVisits = d.data[visitStatus].length;
if (nbVisits === 0) continue;
tooltipText += nbVisits + ' ' + visitStatus + ' visits';
if (i !== statuses.length - 1) tooltipText += '
';
}
tooltip.html(tooltipText)
.style('left', d3.event.pageX + 15 + 'px')
.style('top', d3.event.pageY + 'px');
})
.on('mouseout', d => {
svg.selectAll('rect.year' + d.data.x1.getUTCFullYear())
.attr('fill-opacity', 0);
svg.selectAll('rect.year' + currentYear)
.attr('fill-opacity', 0.3);
tooltip.transition()
.duration(500)
.style('opacity', 0);
})
.on('mousemove', () => {
tooltip.style('left', d3.event.pageX + 15 + 'px')
.style('top', d3.event.pageY + 'px');
})
// callback to select a year after a mouse click
// inside a histogram bar
.on('click', d => {
svg.selectAll('rect.year' + currentYear)
.attr('fill-opacity', 0);
svg.selectAll('rect.yearoutline' + currentYear)
.attr('stroke', 'none');
currentYear = d.data.x1.getUTCFullYear();
svg.selectAll('rect.year' + currentYear)
.attr('fill-opacity', 0.5);
svg.selectAll('rect.yearoutline' + currentYear)
.attr('stroke', 'black');
yearClickCallback(currentYear);
});
// create one stroke only rectangle by displayed year
// that will be displayed on top of the histogram when the user has selected a year
g.append('g')
.selectAll('rect')
.data(yearBins)
.enter().append('rect')
.attr('class', d => 'yearoutline' + d.getUTCFullYear())
.attr('fill', 'none')
.attr('stroke', d => d.getUTCFullYear() === currentYear ? 'black' : 'none')
.attr('x', d => {
let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return x(date);
})
.attr('y', 0)
.attr('height', height)
.attr('width', d => {
let date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
let yearWidth = x(d3.timeYear.offset(date, 1)) - x(date);
return yearWidth;
});
// add x axis with a tick for every 1st day of each year
let xAxis = g.append('g')
.attr('class', 'axis')
.attr('transform', 'translate(0,' + height + ')')
.call(
d3.axisBottom(x)
.ticks(d3.timeYear.every(1))
.tickFormat(d => d.getUTCFullYear())
);
// shift tick labels in order to display them at the middle
// of each year range
xAxis.selectAll('text')
.attr('transform', d => {
let year = d.getUTCFullYear();
let date = new Date(Date.UTC(year, 0, 1));
let yearWidth = x(d3.timeYear.offset(date, 1)) - x(date);
return 'translate(' + -yearWidth / 2 + ', 0)';
});
// add y axis for the number of visits
g.append('g')
.attr('class', 'axis')
.call(d3.axisLeft(y).tickValues(yTickValues));
// add legend for visit statuses
let legendGroup = g.append('g')
.attr('font-family', 'sans-serif')
.attr('font-size', 10)
.attr('text-anchor', 'end');
legendGroup.append('text')
.attr('x', width + margin.right - 5)
.attr('y', 9.5)
.attr('dy', '0.32em')
.text('visit status:');
let legend = legendGroup.selectAll('g')
.data(statuses.slice().reverse())
.enter().append('g')
.attr('transform', (d, i) => 'translate(0,' + (i + 1) * 20 + ')');
legend.append('rect')
.attr('x', width + 2 * margin.right / 3)
.attr('width', 19)
.attr('height', 19)
.attr('fill', colors);
legend.append('text')
.attr('x', width + 2 * margin.right / 3 - 5)
.attr('y', 9.5)
.attr('dy', '0.32em')
.text(d => d);
// add text label for the y axis
g.append('text')
.attr('transform', 'rotate(-90)')
.attr('y', -margin.left)
.attr('x', -(height / 2))
.attr('dy', '1em')
.style('text-anchor', 'middle')
.text('Number of visits');
}
diff --git a/swh/web/templates/browse/origin-visits.html b/swh/web/templates/browse/origin-visits.html
index 7ac93ebc..379c67db 100644
--- a/swh/web/templates/browse/origin-visits.html
+++ b/swh/web/templates/browse/origin-visits.html
@@ -1,85 +1,86 @@
{% extends "./browse.html" %}
{% comment %}
Copyright (C) 2017-2020 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
{% load static %}
{% load swh_templatetags %}
{% load render_bundle from webpack_loader %}
{% block header %}
{{ block.super }}
{% render_bundle 'origin' %}
{% endblock %}
{% block swh-browse-content %}