@gros/prediction-site
Version:
Human-readable output of sprint predictions.
848 lines (791 loc) • 31.5 kB
JavaScript
/**
* Main entry point for the prediction site.
*
* Copyright 2017-2020 ICTU
* Copyright 2017-2022 Leiden University
* Copyright 2017-2023 Leon Helwerda
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import _ from 'lodash';
import * as d3 from 'd3';
import axios from 'axios';
import moment from 'moment';
import mimetype2fa from 'mimetype-to-fontawesome';
import {Locale, Navigation, Navbar, Spinner} from '@gros/visualization-ui';
import config from 'config.json';
import spec from './locale.json';
import {Content, predicateTypes} from './Content';
const ATTR_TOOLTIP = "data-tooltip";
const ATTR_LABEL = "aria-label";
const locales = new Locale(spec, config.language);
const loadingSpinner = new Spinner({
width: d3.select('#prediction-container').node().clientWidth,
height: 100,
startAngle: 220,
container: '#prediction-container',
id: 'loading-spinner'
});
loadingSpinner.start();
// Use hosted files for local debugging (see "localhost" branch)
let dataUrl, listUrl, metaUrl, linksUrl, sourcesUrl, sprintsUrl;
let localeUrl = item => `data/${item}.json`;
let datasetUrl = 'data/sprint_features.arff';
config.navLink = '?project=';
config.sprintLink = '&sprint=';
const searchParams = new URLSearchParams(document.location.search);
let project = searchParams.get("project");
let sprint = searchParams.get("sprint") || 'latest';
let branch = searchParams.get("branch") || 'master';
let organization = searchParams.get("organization");
let apiUrl = (branch, item) => `data/${item}.json`;
let predictionUrl = config.prediction_url;
let combined = searchParams.has("combined") ?
searchParams.get("combined") === "true" : config.combined;
const selectedLocale = locales.select(searchParams.get("lang"));
moment.locale(locales.lang);
// If we are on localhost, use the local files.
// The project prediction data can be overridden using the query string,
// or disabled (index view) using 'index' as query string.
// If we're not on localhost, load the data from the prediction API.
if (document.location.hostname === "localhost") {
if (combined && organization !== '' && organization !== null) {
apiUrl = (branch, item) => `data/${organization}/${item}.json`;
localeUrl = item => `data/${organization}/${item}.json`;
}
listUrl = apiUrl(branch, 'projects');
metaUrl = apiUrl(branch, 'projects_meta');
/* istanbul ignore next */
if (project === 'index' || project === '' || project === null) {
dataUrl = '';
linksUrl = '';
sourcesUrl = '';
sprintsUrl = '';
}
else {
dataUrl = apiUrl(branch, `${project}/${sprint}`);
const suffix = (sprint === 'latest' ? '' : `.${sprint}`);
linksUrl = apiUrl(branch, `${project}/links${suffix}`);
sourcesUrl = apiUrl(branch, `${project}/sources`);
sprintsUrl = apiUrl(branch, `${project}/sprints`);
}
}
else {
// Determine portions of the URL path (normalized for double slashes):
// - The base of the prediction site, which is based on the configuration
// - The prefix, which is based on the current URL and the prediction site
// base, taking parts of the URL path until it no longer matches the base,
// but adjusts for combined organization template in the configuration
// - The trailer, which is the remainder of the URL path after the prefix
// - The final part, which is the last part of the trailer after removing
// a sprint indicator from it
// - The branch, which is a part after a branch indicator somewhere in the
// trailer
// - The project, which is the final part unless that is set to "index.html"
// (which may happen in some rewrite schemes)
// - The sprint, which is either the sprint indicator that is removed from
// the trailer or "latest"
let base;
try {
base = new URL(predictionUrl).pathname;
}
catch (e) {
base = predictionUrl;
}
const baseParts = base.split('/');
const url = document.location.pathname.replace('//', '/');
const parts = url.split('/');
const prefix = _.takeWhile(parts,
(part, index) => baseParts.length > index &&
(baseParts[index] === part || (index > 0 &&
`/${baseParts[index - 1]}/${baseParts[index]}` === "/combined/$organization"))
);
const trailer = _.slice(parts, prefix.length);
if (trailer[trailer.length - 2] === 'sprint') {
sprint = trailer.pop();
trailer.pop();
}
const finalPart = trailer.pop();
const branchIndex = trailer.lastIndexOf('branch', trailer.length - 2);
if (branchIndex !== -1) {
branch = trailer[branchIndex + 1];
}
if (project === null || project === '') {
project = finalPart === 'index.html' ? '' : finalPart;
}
if (sprint === null || sprint === '') {
sprint = "latest";
}
// Set the organization based on the prefix part that matched the
// $organization variable from the base URL from configuration, including
// some sanity checks to avoid setting it to an unrelated part
if (organization === null || organization === '') {
const organizationIndex = baseParts.indexOf("$organization", 1);
organization = organizationIndex !== -1 ? parts[organizationIndex] : "";
if (parts[organizationIndex] === "prediction") {
organization = "";
}
}
// Determine combined prediction from URL.
combined = baseParts.indexOf("combined") !== -1;
// Use the original prediction URL (may contain an $organization variable
// for combined predictions) in the navLink since getUrl may replace it
config.navLink = predictionUrl;
config.navLink += trailer.filter(part => part !== '').join('/');
if (config.navLink.substr(-1) !== '/') {
config.navLink += '/';
}
config.sprintLink = '/sprint/';
if (!config.branch_url.includes('$branch')) {
config.branch_url += '/$branch';
}
// Update the prediction URL to contain the current organization if one was
// detected from the URL parts
predictionUrl = predictionUrl.replace(/\/\$organization/g,
organization ? `/${organization}` : ""
);
// Set the API URLs
apiUrl = (branch, item) => {
const version = (branch === 'master' ? '' : `-${branch}`);
return `${predictionUrl}api/v1${version}/${item}`;
};
dataUrl = project ?
apiUrl(branch, `predict/jira/${project}/sprint/${sprint}`) : '';
listUrl = apiUrl(branch, 'list/jira');
metaUrl = apiUrl(branch, 'list/meta');
sourcesUrl = project ? apiUrl(branch, `links/${project}`) : '';
localeUrl = item => apiUrl(branch, `locale/${item}`);
linksUrl = project ? apiUrl(branch, `links/${project}/sprint/${sprint}`) : '';
sprintsUrl = project ? apiUrl(branch, `predict/jira/${project}/sprints`) : '';
datasetUrl = apiUrl(branch, 'dataset');
}
const branchUrl = function(branch) {
const specialLinks = {
"master": config.master_url
};
const url = new URL(_.get(specialLinks, branch,
config.branch_url.replace(/\$branch/g, branch)
), document.location);
if (url.search === '') {
return `${url.pathname}/`;
}
return `${url.pathname}${url.search}`;
};
/* Create URL for a certain organization, branch, project and/or sprint */
const getUrl = function(params) {
const base = params.branch ? branchUrl(params.branch) : config.navLink;
const org = params.organization || organization;
const orgBase = base.replace(/\/\$organization/g, org ? `/${org}` : "");
const sprintLink = params.sprint ? `${config.sprintLink}${params.sprint}` :
"";
const url = new URL(`${orgBase}${params.project || ""}${sprintLink}`,
document.location
);
// For localhost-based combined data sets or setups not using all rewrite
// rules from visualization-site, we do not have the $organization variable
// within the URL to replace, instead propagate the information through URL
// search parameters
if (orgBase === base) {
if (document.location.hostname === "localhost") {
url.searchParams.set("combined", combined ? "true" : "");
}
if (org !== null && org !== '') {
url.searchParams.set("organization", org);
}
}
// Propagate selected language in the search parameter
if (locales.lang !== config.language) {
url.searchParams.set("lang", locales.lang);
}
return url;
};
/* Create selection boxes for filtering the project navigation */
const buildProjectFilter = function(projectNavigation, projects, currentProject, hasMetadata) {
if (projects.length === 0 || (hasMetadata &&
_.every(projects, (project) => project.recent === projects[0].recent)
)) {
return projects;
}
const isRecent = (_.find(projects,
(project) => project.name === currentProject
) || {recent: hasMetadata}).recent;
const filter = (projects) => {
const filters = {};
d3.selectAll('#filter input').each(function(d) {
const checked = d3.select(this).property('checked');
const bits = d.inverse ? [d.inverse, !checked] : [d.key, checked];
if (bits[1]) {
filters[bits[0]] = true;
}
});
return _.filter(projects, filters);
};
const label = d3.select('#filter')
.selectAll('label')
.data([{key: 'recent', default: !!isRecent}])
.enter()
.append('label')
.classed('checkbox tooltip', true)
.attr('disabled', hasMetadata ? null : true)
.attr(ATTR_TOOLTIP,
d => locales.attribute("project-filter-title", d.key)
);
label.append('input')
.attr('type', 'checkbox')
.property('checked', d => d.default)
.attr('disabled', hasMetadata ? null : true)
.on('change', () => {
projectNavigation.update(filter(projects));
});
label.append('span')
.text(d => locales.attribute("project-filter", d.key));
return filter(projects);
};
/* Create project navigation */
const makeProjectNavigation = function(projectList, currentProject) {
const projectNavigation = new Navigation({
container: '#navigation',
prefix: 'project_',
isActive: project => project === currentProject,
setCurrentItem: (project, hasProject) => {
if (project !== currentProject) {
return false;
}
return true;
},
key: d => d.name,
addElement: (element) => {
element.classed('tooltip has-tooltip-multiline has-tooltip-center', true)
.style("width", "0%")
.style("opacity", "0")
.text(d => d.name)
.attr('href', d => getUrl({project: d.name}))
.attr(ATTR_TOOLTIP, d => locales.message("project-title",
[d.quality_display_name || d.name]
))
.transition()
.style("width", "100%")
.style("opacity", "1");
},
removeElement: (element) => {
element.transition()
.style("opacity", "0")
.remove();
}
});
axios.get(metaUrl)
.then(meta => {
const projectData = _.intersectionBy(meta.data, projectList,
(project) => project.name ? project.name : project
);
const projects = buildProjectFilter(projectNavigation,
projectData, currentProject, true
);
projectNavigation.start(projects);
projectNavigation.setCurrentItem(currentProject);
})
.catch(function(error) {
const projectData = _.zipWith(projectList, key => {
return {name: key};
});
const projects = buildProjectFilter(projectNavigation,
projectData, currentProject, false
);
projectNavigation.start(projects);
projectNavigation.setCurrentItem(currentProject);
});
};
/* Create organization navigation (only for combined predictions */
const makeOrganizationNavigation = function(organizations, currentOrganization) {
if (_.size(organizations) <= 1) {
return;
}
d3.select('#organizations-container').classed('is-hidden', false);
const organizationNavigation = new Navigation({
container: '#organizations-navigation',
prefix: 'organization_',
isActive: organization => organization === currentOrganization,
setCurrentItem: (organization, hasOrganization) => {
if (organization !== currentOrganization) {
return false;
}
return true;
},
addElement: (element) => {
element.style("width", "0%")
.style("opacity", "0")
.text(d => locales.retrieve(config.organizations, d))
.attr('href', d => getUrl({organization: d}))
.classed("tooltip", true)
.attr(ATTR_TOOLTIP, d => locales.message("organization-title",
[locales.retrieve(config.organizations, d)]
))
.transition()
.style("width", "100%")
.style("opacity", "1");
},
removeElement: (element) => {
element.transition()
.style("opacity", "0")
.remove();
}
});
organizationNavigation.start(organizations);
organizationNavigation.setCurrentItem(currentOrganization);
};
/* Create content within the page (possibly nested) */
const makeContent = function(context) {
const displayValue = function(key, value, element, context) {
const type = (_.find(predicateTypes,
(predicate) => predicate[0](value, element, key, context)
) || [null, Content])[1];
const content = new type(key, value, element, context);
return content.display();
};
context = _.assign({}, {
container: d3,
selector: '#',
config, locales, moment,
formatLocale: d3.formatLocale(selectedLocale),
build: makeContent,
display: displayValue
}, context);
// Add prediction values to the page
let shownKeys = new Set();
_.forOwn(context.data, (value, key) => {
if (typeof context.fallback[key] === "function") {
value = context.fallback[key](value);
}
else if (value === null) {
value = context.fallback[key];
}
if (value === null || value === undefined) {
return;
}
const element = context.container.select(context.selector + key);
if (!element.empty() && context.display(key, value, element, context)) {
shownKeys.add(key);
}
});
context.container.selectAll('[data-show]').filter(function() {
return !shownKeys.has(this.getAttribute('data-show'));
}).classed('is-hidden', true);
};
/* Adjust a mimetype-to-fontawesome icon to newer FontAwesome specification */
const updateFileIcon = function(icon) {
return icon.replace(/-o$/, '').replace(/-text$/, '-alt');
};
/* Create cards for file resources */
const makeFiles = function(data) {
const files = data.data.files.filter(file => file.type !== 'dir');
const cards = d3.select('#files').selectAll('.card').data(files);
const card = cards.enter().append('div')
.classed('card', true);
const cardContent = card.append('div')
.classed('card-content', true);
const media = cardContent.append('div')
.classed('media', true);
media.append('div')
.classed('media-left', true)
.html(d => `<i class="far ${updateFileIcon(mimetype2fa(d.mimetype, {prefix: 'fa-'}))}"></i>`);
media.append('div')
.classed('media-content', true)
.html(d => `<a href="${config.papers_url}/${d.name}">${d.name}</a>`);
};
const updateDropdown = function(dropdown, menu, target=null) {
if (target !== null) {
target.classed('is-hidden', false)
.append(() => dropdown.remove().node());
}
menu.attr('role', 'menu');
dropdown.classed('is-hidden', false);
dropdown.select('.dropdown-trigger button')
.attr('aria-controls', 'branches-menu');
dropdown.select('.dropdown-trigger').on('click', () => {
const active = !dropdown.classed('is-active');
dropdown.classed('is-active', active);
dropdown.select('.dropdown-trigger button')
.classed('is-outlined', !active);
dropdown.select('.dropdown-trigger .icon i')
.classed('fa-angle-up', active)
.classed('fa-angle-down', !active);
});
};
/* Use configuration from a prediction branch to describe the menu item */
const addBranchConfiguration = function(descriptions, data, d, branch) {
const labels = Array.isArray(data.labels) ? data.labels : [data.labels];
const id = `branch-${data.model}-${labels.join('-')}`;
const modelBranch = d3.select(`#${id}`);
branch.select('span.branch')
.text(locales.attribute('model', data.model));
if (data.labels) {
branch.append('span')
.text(locales.message('branch-labels', [
_.map(labels, label => locales.retrieve(descriptions, label)).join(', ')
]));
}
if (d.name === "master") {
branch.append('span')
.text(locales.message('branch-default'));
}
else if (!modelBranch.empty()) {
const addName = branch => {
branch.append('span')
.classed('name', true)
.text(d => locales.message('branch-same-name', [d.name]));
};
addName(branch);
if (modelBranch.select('.name').empty()) {
addName(modelBranch);
}
}
else {
branch.attr('id', id);
}
};
/* Create a menu of prediction branches with different experiments/models */
const makeBranches = function(descriptions, target=null) {
if (!config.branches_url) {
return;
}
axios.get(config.branches_url)
.then(response => {
const container = d3.select('#branches');
const branches = container.selectAll('.dropdown-item')
.data(_.map(_.filter(response.data.jobs,
d => d.name.match(config.branches_filter || "")),
d => {
if (config.branches_alter) {
d.name = d.name.replace(
new RegExp(config.branches_alter), ""
);
}
return d;
}
));
const items = branches.enter().append('a')
.classed('dropdown-item', true)
.classed('is-active', d => d.name === branch)
.attr('role', 'menuitem')
.attr('href', d => getUrl({project, branch: d.name}));
items.append('i')
.classed('fas', true)
.classed('fa-flask', true);
items.append('span')
.classed('branch', true)
.text(d => d.name);
items.each(function(d) {
axios.get(apiUrl(d.name, 'configuration'))
.then(configuration => {
addBranchConfiguration(descriptions,
configuration.data, d, d3.select(this)
);
if (d.name === branch) {
makeOrganizationNavigation(configuration.data.organizations, decodeURIComponent(organization));
}
})
.catch(error => {
d3.select(this)
.attr('aria-hidden', d => d.name !== branch)
.classed('is-hidden', d => d.name !== branch);
});
});
// Don't show the dropdown if there are not branches known.
// We could filter on :not(.is-hidden) here, but that only
// applies after configuration requests are done, and there
// should always be the current branch if there are branches.
if (items.empty()) {
return;
}
updateDropdown(d3.select('#branches-dropdown'),
d3.select('#branches-menu'), target
);
})
.catch((error) => {
throw error;
});
};
/* Create a sprint navigation for projects with multiple predicted sprints */
const makeSprints = function(data, project, sprints, container) {
container.classed('is-hidden', sprints.length <= 1)
.selectAll('li')
.data(sprints)
.enter()
.append('li')
.classed('is-active', d => _.isObject(d) ?
d.sprint_num === data.sprint : d === data.sprint
)
.append('a')
.attr('href', d => getUrl({project, sprint: d.sprint_id || d}))
.text(d => _.isObject(d) && d.sprint_num !== data.sprint ?
locales.message('sprint-link-name', [d.sprint_num, d.name]) :
locales.message('sprint-link', [d.sprint_num || d]));
};
/* Handle a click on a toggle icon for expanding/collapsing a large container */
const clickToggle = function(container, hidden, d, toggle) {
container.classed('is-hidden', false)
.style('opacity', hidden ? 0 : 1)
.transition()
.style('opacity', hidden ? 1 : 0)
.on("end", function() {
d3.select(this).classed('is-hidden', !hidden);
});
toggle.attr('aria-expanded', hidden ? "true" : "false")
.attr(ATTR_LABEL, locales.message(`${d.toggle}-${hidden ? "hide" : "show"}`))
.attr(ATTR_TOOLTIP, locales.message(`${d.toggle}-${hidden ? "hide" : "show"}`))
.select('i')
.classed("fa-chevron-down", hidden)
.classed("fa-chevron-right", !hidden);
};
/* Add export buttons */
const makeExport = function(context=null, requestUrl='', target=null) {
const options = [
{
'name': 'dataset',
'icon': ['fas', 'fa-file-alt'],
'url': () => datasetUrl
}
];
if (!_.isEmpty(context)) {
options.push({
'name': 'json',
'icon': ['fas', 'fa-shapes'],
'url': () => URL.createObjectURL(new Blob(
[window.JSON.stringify(context, null, 4)],
{type: 'application/json'}
))
});
}
if (requestUrl !== '') {
options.push({
'name': 'api',
'icon': ['fas', 'fa-server'],
'url': () => requestUrl
});
}
if (config.openapi_url) {
options.push({
'name': 'openapi',
'icon': ['fas', 'fa-cogs'],
'url': () => config.openapi_url
});
}
const container = d3.select('#export-options');
const item = container.selectAll('.dropdown-item')
.data(options)
.enter()
.append('a')
.classed('dropdown-item', true)
.attr('role', 'menuitem')
.attr('id', d => `export-${d.name}`);
item.append('i')
.attr('class', d => d.icon.join(' '))
.classed('icon is-small', true);
item.append('span')
.text(d => locales.attribute('export', d.name));
item.on('click', function(event, d) {
const link = d3.select(document.body)
.append('a')
.classed('is-hidden', true)
.attr('target', '_blank')
.attr('href', d.url());
link.node().click();
link.remove();
});
updateDropdown(d3.select('#export-dropdown'), d3.select('#export-menu'),
target
);
};
/* Add toggles to collapse/expand large containers */
const makeToggles = function() {
d3.selectAll('main .container .toggle')
.classed('tooltip', true)
.datum(function() {
return this.dataset;
})
.each((d, i, nodes) => {
const hidden = d3.select(`#${d.toggle}`).classed('is-hidden');
const label = locales.message(`${d.toggle}-${hidden ? "show" : "hide"}`);
d3.select(nodes[i])
.attr(ATTR_LABEL, label)
.attr(ATTR_TOOLTIP, label)
.append('i')
.classed(`fas fa-chevron-${hidden ? "right" : "down"}`, true);
})
.on('click', function(event, d) {
const container = d3.select(`#${d.toggle}`);
const hidden = container.classed('is-hidden');
const toggle = d3.select(this);
clickToggle(container, hidden, d, toggle);
});
};
/* Build the entire page */
const makePage = function(projectList, organization, project, sprints, dataContext) {
const fallback = {
organization: decodeURIComponent(organization),
project: decodeURIComponent(project),
name: dataContext.data.id === null ? "" :
locales.message("sprint-view"),
features: (values) => _.pick(values,
dataContext.data.configuration.features
)
};
const context = _.assign({}, fallback, dataContext, {fallback,
localization: _.assign({}, {
features: {},
sources: {},
tags: {},
units: {},
short_units: {},
metadata: {},
organizations: config.organizations
}, dataContext.localization)
});
makeProjectNavigation(projectList, context.project);
makeOrganizationNavigation(context.data.configuration.organizations,
context.organization
);
makeBranches(context.localization.descriptions || {},
d3.select("#branches-target")
);
makeSprints(context.data, project, sprints || [], d3.select('#sprints'));
makeContent(context);
makeExport(context, dataUrl, d3.select("#export-target"));
makeToggles();
d3.select('#overview').classed('is-hidden', false);
loadingSpinner.stop();
};
// Retrieve the appropriate data, either a navigation index or a project sprint
// prediction page
if (dataUrl === '') {
const promises = [];
// Build the navigation
promises.push(axios.get(listUrl)
.then(list => {
makeProjectNavigation(list.data);
makeExport({project: list.data}, listUrl);
})
.catch(function(error) {
loadingSpinner.stop();
// Do not show error for combined data set index because there is no
// list of projects when no organization is selected yet.
if (combined && !organization) {
makeExport({}, apiUrl(branch, 'configuration'));
}
else {
d3.select('#prediction-error-message')
.classed('is-hidden', false)
.text(locales.message("error-projects", [error]));
}
}));
promises.push(axios.get(localeUrl('descriptions'))
.then(descriptions => {
makeBranches(descriptions.data);
})
.catch(function(error) {
makeBranches({});
}));
if (config.files_url) {
promises.push(axios.get(config.files_url)
.then(response => {
d3.select('#files-title').classed('is-hidden', false);
makeFiles(response.data);
})
.catch(function(error) {
d3.select('#files-error-message')
.classed('is-hidden', false)
.text(locales.message("error-files", [error]));
}));
}
axios.all(promises).then(() => {
loadingSpinner.stop();
}).catch(function (error) {
loadingSpinner.stop();
throw error;
});
}
else {
axios.get(listUrl).then(function(projectList) {
const projects = projectList.data;
axios.get(dataUrl).then(function(predictionData) {
const data = predictionData.data;
axios.all([
axios.get(localeUrl('descriptions')),
axios.get(localeUrl('sources')),
axios.get(localeUrl('tags')),
axios.get(localeUrl('units')),
axios.get(localeUrl('short_units')),
axios.get(localeUrl('metadata'))
])
.then(axios.spread(function (descriptions, sources, tags, units, shortUnits, metadata) {
const localization = {
features: descriptions.data,
sources: sources.data,
tags: tags.data,
units: units.data,
short_units: shortUnits.data,
metadata: metadata.data,
organizations: config.organizations
};
axios.all([
axios.get(linksUrl),
axios.get(sourcesUrl),
axios.get(sprintsUrl)
]).then(axios.spread(function (links, sourceLinks, sprints) {
makePage(projects, organization, project, sprints.data, {
data, localization,
links: links.data,
sources: sourceLinks.data
});
}))
.catch(function(error) {
makePage(projects, organization, project, [], {
data, localization,
links: {},
sources: {}
});
});
}))
.catch(function(error) {
makePage(projects, organization, project, [], {
data,
links: {},
sources: {}
});
});
})
.catch(function(error) {
makeProjectNavigation(projects);
loadingSpinner.stop();
d3.select('#prediction-error-message')
.classed('is-hidden', false)
.text(locales.message("error-prediction", [error]));
});
})
.catch(function(error) {
loadingSpinner.stop();
d3.select('#prediction-error-message')
.classed('is-hidden', false)
.text(locales.message("error-projects", [error]));
throw error;
});
}
locales.updateMessages();
locales.updateMessages(d3.select('main'), [ATTR_TOOLTIP]);
if (typeof window.buildNavigation === "function") {
window.buildNavigation(Navbar, locales, _.assign({}, config, {
visualization: 'prediction',
language_page: getUrl({branch, project, sprint: project ? sprint : ''}),
language_query: 'lang'
}));
}