diff --git a/modules/dashboard/test/DashboardTest.php b/modules/dashboard/test/DashboardTest.php index 4a42da2ca7c..e3feae67c2b 100644 --- a/modules/dashboard/test/DashboardTest.php +++ b/modules/dashboard/test/DashboardTest.php @@ -358,30 +358,52 @@ public function testDashboardRecruitmentView() $this->safeGet($this->url . '/dashboard/'); $views = $this->safeFindElement( WebDriverBy::cssSelector( - "#statistics_widgets .panel:nth-child(1) .views button" + "#statistics_widgets .panel:nth-child(2) .views button" ) ); $views->click(); $assertText1 = $this->safeFindElement( WebDriverBy::cssSelector( - "#statistics_widgets .panel:nth-child(1)". + "#statistics_widgets .panel:nth-child(2)". " .dropdown-menu li:nth-child(1)" ) )->getText(); $assertText2 = $this->safeFindElement( WebDriverBy::cssSelector( - "#statistics_widgets .panel:nth-child(1)". + "#statistics_widgets .panel:nth-child(2)". " .dropdown-menu li:nth-child(2)" ) )->getText(); + $assertText3 = $this->safeFindElement( + WebDriverBy::cssSelector( + "#statistics_widgets .panel:nth-child(2)". + " .dropdown-menu li:nth-child(3)" + ) + )->getText(); + + $assertText4 = $this->safeFindElement( + WebDriverBy::cssSelector( + "#statistics_widgets .panel:nth-child(2)". + " .dropdown-menu li:nth-child(4)" + ) + )->getText(); + $this->assertStringContainsString("Recruitment - overall", $assertText1); $this->assertStringContainsString( "Recruitment - site breakdown", $assertText2 ); + $this->assertStringContainsString( + "Recruitment - project breakdown", + $assertText3 + ); + $this->assertStringContainsString( + "Recruitment - cohort breakdown", + $assertText4 + ); } /** diff --git a/modules/statistics/css/WidgetIndex.css b/modules/statistics/css/WidgetIndex.css new file mode 100644 index 00000000000..00a2306accc --- /dev/null +++ b/modules/statistics/css/WidgetIndex.css @@ -0,0 +1,3 @@ +.c3-tooltip-container { + top: 0px !important; +} \ No newline at end of file diff --git a/modules/statistics/jsx/WidgetIndex.js b/modules/statistics/jsx/WidgetIndex.js index 389fd6d90ec..e87bb527936 100644 --- a/modules/statistics/jsx/WidgetIndex.js +++ b/modules/statistics/jsx/WidgetIndex.js @@ -4,10 +4,13 @@ import PropTypes from 'prop-types'; import Recruitment from './widgets/recruitment'; import StudyProgression from './widgets/studyprogression'; import {fetchData} from './Fetch'; -import { - recruitmentCharts, - studyProgressionCharts, -} from './widgets/chartBuilder'; +import Modal from 'Modal'; +import Loader from 'Loader'; +import {SelectElement} from 'jsx/Form'; + +import '../css/WidgetIndex.css'; + +import {setupCharts} from './widgets/helpers/chartBuilder'; /** * WidgetIndex - the main window. @@ -18,6 +21,176 @@ import { const WidgetIndex = (props) => { const [recruitmentData, setRecruitmentData] = useState({}); const [studyProgressionData, setStudyProgressionData] = useState({}); + + const [modalChart, setModalChart] = useState(null); + + // used by recruitment.js and studyprogression.js to display each chart. + const showChart = (section, chartID, chartDetails, setChartDetails) => { + let {sizing, title, chartType, options} = chartDetails[section][chartID]; + return
+
+
+ {title} +
+ {Object.keys(chartDetails[section][chartID].options).length > 1 && + { + // set the chart type in the chartDetails object for that chartID + setChartDetails( + {...chartDetails, + [section]: { + ...chartDetails[section], + [chartID]: { + ...chartDetails[section][chartID], + chartType: options[value], + }, + }, + }); + setupCharts( + false, + {[section]: {[chartID]: { + ...chartDetails[section][chartID], + chartType: options[value]}, + }} + ); + }} + /> + } +
+ { + setModalChart(chartDetails[section][chartID]); + setupCharts( + true, + {[section]: {[chartID]: chartDetails[section][chartID]}} + ); + }} + id={chartID} + > + + +
; + }; + + const downloadAsCSV = (data, filename, dataType) => { + const convertBarToCSV = (data) => { + const csvRows = []; + + // Adding headers row + const headers = ['Labels', ...Object.keys(data.datasets)]; + csvRows.push(headers.join(',')); + + // Adding data rows + const maxDatasetLength = Math.max(...Object.values(data.datasets).map( + (arr) => arr.length) + ); + for (let i = 0; i < maxDatasetLength; i++) { + const values = [`"${data.labels[i]}"` || '']; // Label for this row + for (const datasetKey of Object.keys(data.datasets)) { + const value = data.datasets[datasetKey][i]; + values.push(`"${value}"` || ''); + } + csvRows.push(values.join(',')); + } + return csvRows.join('\n'); + }; + + const convertPieToCSV = (data) => { + const csvRows = []; + const headers = Object.keys(data[0]); + csvRows.push(headers.join(',')); + + for (const row of data) { + const values = headers.map((header) => { + const escapedValue = row[header].toString().replace(/"/g, '\\"'); + return `"${escapedValue}"`; + }); + csvRows.push(values.join(',')); + } + + return csvRows.join('\n'); + }; + + const convertLineToCSV = (data) => { + const csvRows = []; + + // Adding headers row + const headers = [ + 'Labels', + ...data.datasets.map((dataset) => dataset.name), + ]; + csvRows.push(headers.join(',')); + + // Adding data rows + for (let i = 0; i < data.labels.length; i++) { + const values = [data.labels[i]]; // Label for this row + for (const dataset of data.datasets) { + values.push(dataset.data[i] || ''); + } + csvRows.push(values.join(',')); + } + + return csvRows.join('\n'); + }; + + let csvData = ''; + if (dataType == 'pie') { + csvData = convertPieToCSV(data); + } else if (dataType == 'bar') { + csvData = convertBarToCSV(data); + } else if (dataType == 'line') { + csvData = convertLineToCSV(data); + } + const blob = new Blob([csvData], {type: 'text/csv'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + // used by recruitment.js and studyprogression.js to update the filters for each chart. + const updateFilters = ( + formDataObj, + section, + chartDetails, + setChartDetails + ) => { + let formObject = new FormData(); + for (const key in formDataObj) { + if (formDataObj[key] != '' && formDataObj[key] != ['']) { + formObject.append(key, formDataObj[key]); + } + } + const queryString = '?' + new URLSearchParams(formObject).toString(); + + let newChartDetails = {...chartDetails}; + Object.keys(chartDetails[section]).forEach((chart) => { + // update filters + let newChart = {...chartDetails[section][chart], filters: queryString}; + setupCharts(false, {[section]: {[chart]: newChart}}).then((data) => { + // update chart data + newChartDetails[section][chart] = data[section][chart]; + }); + }); + setChartDetails(newChartDetails); + }; + /** * Similar to componentDidMount and componentDidUpdate. */ @@ -33,9 +206,6 @@ const WidgetIndex = (props) => { ); setRecruitmentData(data); setStudyProgressionData(data); - // setup statistics for c3.js charts. - await studyProgressionCharts(); - await recruitmentCharts(); }; setup().catch((error) => { console.error(error); @@ -49,11 +219,80 @@ const WidgetIndex = (props) => { */ return ( <> + setModalChart(null)} + width={'1200px'} + title={modalChart && modalChart.title} + throwWarning={false} + > +
+
+ +
+
+ {modalChart && modalChart.chartType && + { + downloadAsCSV( + modalChart.data, + modalChart.title, + modalChart.dataType + ); + }} + className='btn btn-info'> + + } + {modalChart + && modalChart.chartType + && modalChart.chartType !== 'line' + && { + exportChartAsImage('dashboardModal'); + }} + className='btn btn-info'> + + } +
); @@ -74,3 +313,63 @@ window.addEventListener('load', () => { /> ); }); + +/** + * Helper function to export a chart as an image + * + * @param {string} chartId + */ +const exportChartAsImage = (chartId) => { + const chartContainer = document.getElementById(chartId); + + if (!chartContainer) { + console.error(`Chart with ID '${chartId}' not found.`); + return; + } + + // Get the SVG element that represents the chart + const svgNode = chartContainer.querySelector('svg'); + + // Clone the SVG node to avoid modifying the original chart + const clonedSvgNode = svgNode.cloneNode(true); + + // Modify the font properties of the text elements + const textElements = clonedSvgNode.querySelectorAll('text'); + textElements.forEach((textElement) => { + textElement.style.fontFamily = 'Arial, sans-serif'; + textElement.style.fontSize = '12px'; + }); + + // Create a canvas element + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + // Get the SVG as XML data + const svgData = new XMLSerializer().serializeToString(clonedSvgNode); + + // Create an image that can be used as the source for the canvas + const img = new Image(); + img.onload = () => { + // Set the canvas size to match the chart's size + canvas.width = img.width; + canvas.height = img.height; + + // Draw the image on the canvas + ctx.drawImage(img, 0, 0); + + // Export the canvas to a data URL + const dataURL = canvas.toDataURL('image/png'); + + // Create a link and trigger a download + const link = document.createElement('a'); + link.href = dataURL; + link.download = 'chart.png'; + link.click(); + + // Clean up + canvas.remove(); + }; + img.src = + 'data:image/svg+xml;base64,' + + btoa(unescape(encodeURIComponent(svgData))); +}; diff --git a/modules/statistics/jsx/widgets/chartBuilder.js b/modules/statistics/jsx/widgets/chartBuilder.js deleted file mode 100644 index eb48d62dbd8..00000000000 --- a/modules/statistics/jsx/widgets/chartBuilder.js +++ /dev/null @@ -1,343 +0,0 @@ -import 'c3/c3.min.css'; -import c3 from 'c3'; -import {select} from 'd3'; -import {fetchData} from '../Fetch'; - -const baseURL = window.location.origin; - -// Charts -let scanLineChart; -let recruitmentPieChart; -let recruitmentBarChart; -let recruitmentLineChart; - -// Colours for all charts broken down by only by site -const siteColours = [ - '#F0CC00', '#27328C', '#2DC3D0', '#4AE8C2', '#D90074', '#7900DB', '#FF8000', - '#0FB500', '#CC0000', '#DB9CFF', '#8c564b', '#c49c94', '#e377c2', '#f7b6d2', - '#7f7f7f', '#c7c7c7', '#bcbd22', '#dbdb8d', '#17becf', '#9edae5', -]; - -// Colours for the recruitment bar chart: breakdown by sex -const sexColours = ['#2FA4E7', '#1C70B6']; - -/** - * elementVisibility - used to resize charts when element becomes visible. - * - * @param {HTMLElement} element - * @param {function} callback - */ -const elementVisibility = (element, callback) => { - const options = { - root: document.documentElement, - }; - const observer = new IntersectionObserver((entries, observer) => { - entries.forEach((entry) => { - callback(entry.intersectionRatio > 0); - }); - }, options); - observer.observe(element); -}; - -/** - * formatPieData - used for the recruitment widget - * - * @param {object} data - * @return {*[]} - */ -const formatPieData = (data) => { - const processedData = []; - for (const [i] of Object.entries(data)) { - const siteData = [data[i].label, data[i].total]; - processedData.push(siteData); - } - return processedData; -}; - -/** - * formatBarData - used for the recruitment widget - * - * @param {object} data - * @return {*[]} - */ -const formatBarData = (data) => { - const processedData = []; - if (data['datasets']) { - const females = ['Female']; - processedData.push(females.concat(data['datasets']['female'])); - } - if (data['datasets']) { - const males = ['Male']; - processedData.push(males.concat(data['datasets']['male'])); - } - return processedData; -}; - -/** - * formatLineData - used for the study progression widget - * - * @param {object} data - * @return {*[]} - */ -const formatLineData = (data) => { - const processedData = []; - const labels = []; - labels.push('x'); - for (const [i] of Object.entries(data.labels)) { - labels.push(data.labels[i]); - } - processedData.push(labels); - for (const [i] of Object.entries(data['datasets'])) { - const dataset = []; - dataset.push(data['datasets'][i].name); - processedData.push(dataset.concat(data['datasets'][i].data)); - } - const totals = []; - totals.push('Total'); - for (let j = 0; j < data['datasets'][0].data.length; j++) { - let total = 0; - for (let i = 0; i < data['datasets'].length; i++) { - total += parseInt(data['datasets'][i].data[j]); - } - totals.push(total); - } - processedData.push(totals); - return processedData; -}; - -/** - * maxY - used for the study progression widget - * - * @param {object} data - * @return {number} - */ -const maxY = (data) => { - let maxi = 0; - for (let j = 0; j < data['datasets'][0].data.length; j++) { - for (let i = 0; i < data['datasets'].length; i++) { - maxi = Math.max(maxi, parseInt(data['datasets'][i].data[j])); - } - } - return maxi; -}; - -/** - * recruitmentCharts - fetch data for recruitments - */ -const recruitmentCharts = async () => { - // fetch data for the pie chart. - let data = await fetchData( - `${baseURL}/statistics/charts/siterecruitment_pie`, - ); - const recruitmentPieData = formatPieData(data); - recruitmentPieChart = c3.generate({ - bindto: '#recruitmentPieChart', - size: { - width: 227, - }, - data: { - columns: recruitmentPieData, - type: 'pie', - }, - color: { - pattern: siteColours, - }, - }); - elementVisibility(recruitmentPieChart.element, (visible) => { - if (visible) { - recruitmentPieChart.resize(); - } - }); - - // fetch data for the bar chart. - data = await fetchData( - `${baseURL}/statistics/charts/siterecruitment_bysex`, - ); - const recruitmentBarData = formatBarData(data); - const recruitmentBarLabels = data.labels; - recruitmentBarChart = c3.generate({ - bindto: '#recruitmentBarChart', - size: { - width: 461, - }, - data: { - columns: recruitmentBarData, - type: 'bar', - }, - axis: { - x: { - type: 'categorized', - categories: recruitmentBarLabels, - }, - y: { - label: 'Candidates registered', - }, - }, - color: { - pattern: sexColours, - }, - }); - elementVisibility(recruitmentBarChart.element, (visible) => { - if (visible) { - recruitmentBarChart.resize(); - } - }); -}; - -/** - * studyProgressionCharts - fetch data for study progression - */ -const studyProgressionCharts = async () => { - // fetch data for the line chart. - let data = await fetchData( - `${baseURL}/statistics/charts/scans_bymonth`, - ); - let legendNames = []; - for (let j = 0; j < data['datasets'].length; j++) { - legendNames.push(data['datasets'][j].name); - } - const scanLineData = formatLineData(data); - scanLineChart = c3.generate({ - size: { - height: '100%', - }, - bindto: '#scanChart', - data: { - x: 'x', - xFormat: '%m-%Y', - columns: scanLineData, - type: 'area-spline', - }, - legend: { - show: false, - }, - axis: { - x: { - type: 'timeseries', - tick: { - format: '%m-%Y', - }, - }, - y: { - max: maxY(data), - label: 'Scans', - }, - }, - zoom: { - enabled: true, - }, - color: { - pattern: siteColours, - }, - }); - select('.scanChartLegend') - .insert('div', '.scanChart') - .attr('class', 'legend') - .selectAll('div').data(legendNames).enter() - .append('div') - .attr('data-id', function(id) { - return id; - }) - .html(function(id) { - return '' + id; - }) - .each(function(id) { - select(this).select('span').style( - 'background-color', scanLineChart.color(id), - ); - }) - .on('mouseover', function(id) { - scanLineChart.focus(id); - }) - .on('mouseout', function(id) { - scanLineChart.revert(); - }) - .on('click', function(id) { - scanLineChart.toggle(id); - }); - elementVisibility(scanLineChart.element, (visible) => { - if (visible) { - scanLineChart.resize(); - } - }); - // scanLineChart.resize(); - - // fetch data for the line chart. - data = await fetchData( - `${baseURL}/statistics/charts/siterecruitment_line`, - ); - legendNames = []; - for (let j = 0; j < data['datasets'].length; j++) { - legendNames.push(data['datasets'][j].name); - } - const recruitmentLineData = formatLineData(data); - recruitmentLineChart = c3.generate({ - size: { - height: '100%', - }, - bindto: '#recruitmentChart', - data: { - x: 'x', - xFormat: '%m-%Y', - columns: recruitmentLineData, - type: 'area-spline', - }, - legend: { - show: false, - }, - axis: { - x: { - type: 'timeseries', - tick: { - format: '%m-%Y', - }, - }, - y: { - max: maxY(data), - label: 'Candidates registered', - }, - }, - zoom: { - enabled: true, - }, - color: { - pattern: siteColours, - }, - }); - select('.recruitmentChartLegend') - .insert('div', '.recruitmentChart') - .attr('class', 'legend') - .selectAll('div').data(legendNames).enter() - .append('div') - .attr('data-id', function(id) { - return id; - }) - .html(function(id) { - return '' + id; - }) - .each(function(id) { - select(this).select('span').style( - 'background-color', - recruitmentLineChart.color(id)); - }) - .on('mouseover', function(id) { - recruitmentLineChart.focus(id); - }) - .on('mouseout', function(id) { - recruitmentLineChart.revert(); - }) - .on('click', function(id) { - recruitmentLineChart.toggle(id); - }); - elementVisibility(recruitmentLineChart.element, (visible) => { - if (visible) { - recruitmentLineChart.resize(); - } - }); - // recruitmentLineChart.resize(); -}; - -export { - recruitmentCharts, - studyProgressionCharts, -}; diff --git a/modules/statistics/jsx/widgets/helpers/chartBuilder.js b/modules/statistics/jsx/widgets/helpers/chartBuilder.js new file mode 100644 index 00000000000..062e2f02847 --- /dev/null +++ b/modules/statistics/jsx/widgets/helpers/chartBuilder.js @@ -0,0 +1,371 @@ +/* eslint-disable */ +import 'c3/c3.min.css'; +import c3 from 'c3'; +import {fetchData} from '../../Fetch'; + +const baseURL = window.location.origin; + +// Colours for all charts broken down by only by site +const siteColours = [ + '#F0CC00', '#27328C', '#2DC3D0', '#4AE8C2', '#D90074', '#7900DB', '#FF8000', + '#0FB500', '#CC0000', '#DB9CFF', '#8c564b', '#c49c94', '#e377c2', '#f7b6d2', + '#7f7f7f', '#c7c7c7', '#bcbd22', '#dbdb8d', '#17becf', '#9edae5', +]; + +// Colours for the recruitment bar chart: breakdown by sex +const sexColours = ['#2FA4E7', '#1C70B6']; + +/** + * onload - override link click to cancel any fetch for statistical data. + */ +window.onload = () => { + document.body.addEventListener('click', (e) => { + // User clicks on a link.. + if ( + e.target && + e.target.nodeName === 'A' && + e.target.hasAttribute('data-target') === false + ) { + window.stop(); + } else if ( + e.target && + e.target.nodeName === 'A' && + e.target.hasAttribute('data-target') === true + ) { + const myTimeout = setTimeout(() => { + resizeGraphs(); + clearTimeout(myTimeout); + }, 500); + } + }); +}; + +let charts = [] +const resizeGraphs = () => { + charts.forEach((chart) => { + if (chart !== undefined) { + elementVisibility(chart.element, (visible) => { + if (visible) { + chart.resize(); + } + }) + } + }) +}; + +/** + * elementVisibility - used to resize charts when element becomes visible. + * @param {HTMLElement} element + * @param {function} callback + */ +const elementVisibility = (element, callback) => { + const options = { + root: document.documentElement, + }; + const observer = new IntersectionObserver((entries, observer) => { + entries.forEach((entry) => { + callback(entry.intersectionRatio > 0); + }); + }, options); + observer.observe(element); +}; + +/** + * formatPieData - used for the recruitment widget + * @param {object} data + * @return {[]} + */ +const formatPieData = (data) => { + const processedData = []; + for (const [i] of Object.entries(data)) { + const siteData = [data[i].label, data[i].total]; + processedData.push(siteData); + } + return processedData.filter((item) => item[1] > 0); +}; + +/** + * formatBarData - used for the recruitment widget + * @param {object} data + * @return {[]} + */ +const formatBarData = (data) => { + const processedData = []; + if (data['datasets']) { + const females = ['Female']; + processedData.push(females.concat(data['datasets']['female'])); + } + if (data['datasets']) { + const males = ['Male']; + processedData.push(males.concat(data['datasets']['male'])); + } + return processedData; +}; + +const createPieChart = (columns, id, targetModal, colours) => { + let newChart = c3.generate({ + bindto: targetModal ? targetModal : id, + data: { + columns: columns, + type: 'pie', + }, + size: { + height: targetModal ? 700 : 350, + width: targetModal ? 700 : 350, + }, + color: { + pattern: colours, + }, + pie: { + label: { + format: function(value, ratio, id) { + return value + "("+Math.round(100*ratio)+"%)"; + } + } + }, + }); + charts.push(newChart); + resizeGraphs(); +} + +const createBarChart = (labels, columns, id, targetModal, colours, dataType) => { + let newChart = c3.generate({ + bindto: targetModal ? targetModal : id, + data: { + x: dataType == 'pie' && 'x', + columns: columns, + type: 'bar', + colors: dataType === 'pie' ? { + [columns[1][0]]: function (d) { + return colours[d.index]; + } + } : + { + [columns[0][0]]: colours[0], + [columns[1][0]]: colours[1], + } + }, + size: { + width: targetModal ? 1000 : 350, + height: targetModal ? 700 : 350, + }, + axis: { + x: { + type: 'category', + categories: labels, + }, + y: { + label: { + text: 'Candidates registered', + position: 'inner-top' + }, + }, + }, + color: { + pattern: colours, + }, + legend: dataType === 'bar' ? { + position: 'inset', + inset: { + anchor: 'top-right', + x: 20, + y: 10, + step: 2 + } + } : { + show: false + } + }); + charts.push(newChart); + resizeGraphs(); +} + +const createLineChart = (data, columns, id, label, targetModal) => { + let newChart = c3.generate({ + size: { + height: targetModal && 1000, + width: targetModal && 1000 + }, + bindto: targetModal ? targetModal : id, + data: { + x: 'x', + xFormat: '%m-%Y', + columns: columns, + type: 'area-spline', + }, + legend: { + show: targetModal ? true : false, + }, + axis: { + x: { + type: 'timeseries', + tick: { + format: '%m-%Y', + }, + }, + y: { + max: maxY(data), + label: label, + }, + }, + zoom: { + enabled: true, + }, + color: { + pattern: siteColours, + }, + tooltip: { + // hide if 0 + contents: function (d, defaultTitleFormat, defaultValueFormat, color) { + let $$ = this, + config = $$.config, + titleFormat = config.tooltip_format_title || defaultTitleFormat, + nameFormat = config.tooltip_format_name || function (name) { return name; }, + valueFormat = config.tooltip_format_value || defaultValueFormat, + text, i, title, value, name, bgcolor; + for (i = 0; i < d.length; i++) { + if (d[i] && d[i].value == 0) { continue; } + + if (! text) { + title = titleFormat ? titleFormat(d[i].x) : d[i].x; + text = "" + (title || title === 0 ? "" : ""); + } + + name = nameFormat(d[i].name); + value = valueFormat(d[i].value, d[i].ratio, d[i].id, d[i].index); + bgcolor = $$.levelColor ? $$.levelColor(d[i].value) : color(d[i].id); + + text += ""; + text += ""; + text += ""; + text += ""; + } + return text + "
" + title + "
" + name + "" + value + "
"; + } + + } + }); + charts.push(newChart); + resizeGraphs(); +} + +const getChartData = async (target, filters) => { + let query = `${baseURL}/statistics/charts/${target}` + if (filters) { + query = query + filters; + } + return await fetchData(query); +} + +/** + * setupCharts - fetch data for charts + * If data is provided, use that instead of fetching + * There are three types of data provided. Pie, bar and line + * This is determined by the original chart type of the data provided from the API + * If data was provided as a Pie, and the requested chartType is Bar, then the data will be reformatted + */ +const setupCharts = async (targetIsModal, chartDetails) => { + const chartPromises = []; + let newChartDetails = {...chartDetails} + Object.keys(chartDetails).forEach((section) => { + Object.keys(chartDetails[section]).forEach((chartID) => { + let chart = chartDetails[section][chartID]; + let data = chart.data; + const chartPromise = (data && !chart.filters ? Promise.resolve(data) : getChartData(chartID, chart.filters)) + .then((chartData) => { + let columns = {}; + let labels = []; + let colours = []; + if (chart.dataType === 'pie') { + columns = formatPieData(chartData); + colours = siteColours; + // reformating the columns for a bar chart when it was originally pie data + if (chart.chartType == 'bar') { + let newColumns = [['x'], [chart.label]]; + columns.forEach((column, index) => { + newColumns[0].push(column[0]); + newColumns[1].push(column[1]); + labels.push(column[0]); + }); + columns = newColumns; + } + } else if (chart.dataType === 'bar') { + columns = formatBarData(chartData); + labels = chartData.labels; + colours = sexColours; + } else if (chart.dataType === 'line') { + columns = formatLineData(chartData); + if (chart.chartType !== 'line') { + // remove first and last (x and total) + columns = columns.slice(1, columns.length - 1); + } + } + if (chart.chartType === 'pie') { + createPieChart(columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours); + } else if (chart.chartType === 'bar') { + createBarChart(labels, columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours, chart.dataType); + } else if (chart.chartType === 'line') { + createLineChart(chartData, columns, `#${chartID}`, chart.label, targetIsModal && '#dashboardModal'); + } + newChartDetails[section][chartID].data = chartData; + }); + + chartPromises.push(chartPromise); + }); + }); + + await Promise.all(chartPromises); + return newChartDetails; +}; + +/** + * formatLineData - used for the study progression widget + * @param {object} data + * @return {*[]} + */ +const formatLineData = (data) => { + const processedData = []; + const labels = []; + labels.push('x'); + for (const [i] of Object.entries(data.labels)) { + labels.push(data.labels[i]); + } + processedData.push(labels); + for (const [i] of Object.entries(data['datasets'])) { + const dataset = []; + dataset.push(data['datasets'][i].name); + processedData.push(dataset.concat(data['datasets'][i].data)); + } + const totals = []; + totals.push('Total'); + for (let j = 0; j < data['datasets'][0].data.length; j++) { + let total = 0; + for (let i = 0; i < data['datasets'].length; i++) { + total += parseInt(data['datasets'][i].data[j]); + } + totals.push(total); + } + processedData.push(totals); + return processedData; +}; + +/** + * maxY - used for the study progression widget + * @param {object} data + * @return {number} + */ +const maxY = (data) => { + let maxi = 0; + for (let j = 0; j < data['datasets'][0].data.length; j++) { + for (let i = 0; i < data['datasets'].length; i++) { + maxi = Math.max(maxi, parseInt(data['datasets'][i].data[j])); + } + } + return maxi; +}; + +export { + // following used by WidgetIndex.js, + // recruitment.js and studyProgression.js + setupCharts, +}; \ No newline at end of file diff --git a/modules/statistics/jsx/widgets/helpers/progressbarBuilder.js b/modules/statistics/jsx/widgets/helpers/progressbarBuilder.js new file mode 100644 index 00000000000..097f4804808 --- /dev/null +++ b/modules/statistics/jsx/widgets/helpers/progressbarBuilder.js @@ -0,0 +1,95 @@ +/** + * progressBarBuilder - generates the graph content. + * + * @param {object} data - data needed to generate the graph content. + * @return {JSX.Element} the charts to render to the widget panel. + */ +const progressBarBuilder = (data) => { + let title; + let content; + if (data['recruitment_target']) { + title =
+ {data['title']} +
; + if (data['surpassed_recruitment']) { + content = ( +
+

+ The recruitment target ( + {data['recruitment_target']} + ) has been passed. +

+
+
+

+ {data['female_total']}
Females +

+
+
+

+ {data['male_total']}
Males +

+
+

+ Target: {data['recruitment_target']} +

+
+
+ ); + } else { + content = ( +
+
+

+ {data['female_total']}
Females +

+
+
+

+ {data['male_total']}
Males +

+
+

+ Target: {data['recruitment_target']} +

+
+ ); + } + } else { + content = ( +
+ Please add a recruitment target for {data['title']}. +
+ ); + } + return ( + <> + {title} + {content} + + ); + }; + + export { + progressBarBuilder, + }; diff --git a/modules/statistics/jsx/widgets/helpers/queryChartForm.js b/modules/statistics/jsx/widgets/helpers/queryChartForm.js new file mode 100644 index 00000000000..294f719bd9e --- /dev/null +++ b/modules/statistics/jsx/widgets/helpers/queryChartForm.js @@ -0,0 +1,190 @@ +import React, {useEffect, useState} from 'react'; +import PropTypes from 'prop-types'; +import {SelectElement, FormElement, ButtonElement} from 'jsx/Form'; + + +/** + * QueryChartForm - a form used for statistics query to modify graphs/charts. + * + * @param {object} props + * @return {JSX.Element} + */ +const QueryChartForm = (props) => { + const [optionsProjects, setOptionsProjects] = useState({}); + const [optionsCohorts, setOptionsCohorts] = useState({}); + const [optionsSites, setOptionsSites] = useState({}); + const [optionsVisits, setOptionsVisits] = useState({}); + const [optionsStatus, setOptionsStatus] = useState({}); + const [formDataObj, setFormDataObj] = useState({}); + + /** + * useEffect - modified to run when props.data updates. + */ + useEffect(() => { + const json = props.data; + if (json && Object.keys(json).length !== 0) { + let projectOptions = {}; + for (const [key, value] of Object.entries(json['options']['projects'])) { + projectOptions[key] = value; + } + setOptionsProjects(projectOptions); + let cohortOptions = {}; + for ( + const [key, value] of Object.entries(json['options']['cohorts']) + ) { + cohortOptions[key] = value; + } + setOptionsCohorts(cohortOptions); + let siteOptions = {}; + for (const [key, value] of Object.entries(json['options']['sites'])) { + siteOptions[key] = value; + } + setOptionsSites(siteOptions); + let visitOptions = {}; + for (const [key, value] of Object.entries(json['options']['visits'])) { + visitOptions[key] = value; + } + setOptionsVisits(visitOptions); + let participantStatusOptions = {}; + for (const [key, value] of Object.entries( + json['options']['participantStatus'] + )) { + participantStatusOptions[key] = value; + } + setOptionsStatus(participantStatusOptions); + } + }, [props.data]); + + /** + * setFormData - Stores the value of the element in formDataObj state. + * + * @param {string} formElement - name of the form element + * @param {string} value - value of the form element + */ + const setFormData = (formElement, value) => { + setFormDataObj( (prevState) => ({ + ...prevState, + [formElement]: value, + })); + }; + + const resetFilters = () => { + setFormDataObj({}); + }; + + /** + * Renders the React component. + * + * @return {JSX.Element} - React markup for component. + */ + return ( + props.callback(formDataObj)} + method='GET' + > + {Object.keys(props.data['options']['projects']).length > 0 + ? <> +

Project

+ +
+ : null} + {Object.keys(props.data['options']['cohorts']).length > 0 + ? <> +

Cohort

+ +
+ + : null} + {Object.keys(props.data['options']['sites']).length > 0 + ? <> +

Site

+ +
+ + : null} + {Object.keys(props.data['options']['visits']).length > 0 + ? <> +

Visit

+ +
+ + : null} + {Object.keys(props.data['options']['participantStatus']).length > 0 + ? <> +

Status

+ +
+ + : null} +
+ + +
+
+ ); +}; +QueryChartForm.propTypes = { + data: PropTypes.object, + callback: PropTypes.func, + Module: PropTypes.string, + name: PropTypes.string, + id: PropTypes.string, +}; +QueryChartForm.defaultProps = { + data: {}, +}; + +export { + QueryChartForm, +}; diff --git a/modules/statistics/jsx/widgets/recruitment.js b/modules/statistics/jsx/widgets/recruitment.js index 6f4225edf6c..ae206512368 100644 --- a/modules/statistics/jsx/widgets/recruitment.js +++ b/modules/statistics/jsx/widgets/recruitment.js @@ -1,7 +1,11 @@ import React, {useEffect, useState} from 'react'; import PropTypes from 'prop-types'; import Loader from 'Loader'; -import Panel from 'jsx/Panel'; +import Panel from 'Panel'; +import {QueryChartForm} from './helpers/queryChartForm'; +import {progressBarBuilder} from './helpers/progressbarBuilder'; + +import {setupCharts} from './helpers/chartBuilder'; /** * Recruitment - a widget containing statistics for recruitment data. @@ -11,192 +15,144 @@ import Panel from 'jsx/Panel'; */ const Recruitment = (props) => { const [loading, setLoading] = useState(true); - const [overall, setOverall] = useState({}); - const [siteBreakdown, setSiteBreakdown] = useState({}); - const [projectBreakdown, setProjectBreakdown] = useState({}); + let json = props.data; + + const [chartDetails, setChartDetails] = useState({ + 'siteBreakdown': { + 'agerecruitment_pie': { + sizing: 5, + title: 'Total recruitment by Age', + filters: '', + chartType: 'pie', + dataType: 'pie', + label: 'Age (Years)', + options: {pie: 'pie', bar: 'bar'}, + legend: 'under', + }, + 'ethnicity_pie': { + sizing: 5, + title: 'Ethnicity at Screening', + filters: '', + chartType: 'pie', + dataType: 'pie', + label: 'Ethnicity', + options: {pie: 'pie', bar: 'bar'}, + legend: 'under', + }, + 'siterecruitment_pie': { + sizing: 5, + title: 'Total Recruitment per Site', + filters: '', + chartType: 'pie', + dataType: 'pie', + label: 'Participants', + legend: '', + options: {pie: 'pie', bar: 'bar'}, + }, + 'siterecruitment_bysex': { + sizing: 5, + title: 'Biological sex breakdown by site', + filters: '', + chartType: 'bar', + dataType: 'bar', + legend: 'under', + options: {bar: 'bar', pie: 'pie'}, + }, + }, + }); + + const showChart = (section, chartID) => { + return props.showChart(section, chartID, chartDetails, setChartDetails); + }; + + const updateFilters = (formDataObj, section) => { + props.updateFilters(formDataObj, section, chartDetails, setChartDetails); + }; - /** - * useEffect - modified to run when props.data updates. - */ useEffect(() => { - const json = props.data; if (json && Object.keys(json).length !== 0) { - const overallData = ( -
- {progressBarBuilder(json['recruitment']['overall'])} -
- ); - let siteBreakdownData; - if (json['recruitment']['overall'] && - json['recruitment']['overall']['total_recruitment'] > 0 - ) { - siteBreakdownData = ( - <> -
-
- Total recruitment per site -
-
-
-
-
- Biological sex breakdown by site -
-
-
- - ); - } else { - siteBreakdownData = ( -

There have been no candidates registered yet.

- ); - } - let projectBreakdownData = []; - for (const [key, value] of Object.entries(json['recruitment'])) { - if (key !== 'overall') { - projectBreakdownData.push( -
- {progressBarBuilder(value)} -
- ); - } - } - setProjectBreakdown(projectBreakdownData); - setOverall(overallData); - setSiteBreakdown(siteBreakdownData); + setupCharts(false, chartDetails).then((data) => { + setChartDetails(data); + }); + json = props.data; setLoading(false); } }, [props.data]); - /** - * progressBarBuilder - generates the graph content. - * - * @param {object} data - data needed to generate the graph content. - * @return {JSX.Element} the charts to render to the widget panel. - */ - const progressBarBuilder = (data) => { - let title; - let content; - if (data['recruitment_target']) { - title =
- {data['title']} -
; - if (data['surpassed_recruitment']) { - content = ( -
-

- The recruitment target ( - {data['recruitment_target']} - ) has been passed. -

-
-
-

- {data['female_total']}
Females -

-
-
-

- {data['male_total']}
Males -

-
-

- Target: {data['recruitment_target']} -

-
-
- ); - } else { - content = ( -
-
-

- {data['female_total']}
Females -

-
-
-

- {data['male_total']}
Males -

-
-

- Target: {data['recruitment_target']} -

-
- ); - } - } else { - content = ( -
- Please add a recruitment target for {data['title']}. -
- ); - } - return ( - <> - {title} - {content} - - ); - }; - - /** - * Renders the React component. - * - * @return {JSX.Element} - React markup for component. - */ return loading ? : ( - - {overall} - , - title: 'Recruitment - overall', - }, - { - content: + <> + { + setupCharts(false, chartDetails); + }} + views={[ + { + content: +
+ {progressBarBuilder(json['recruitment']['overall'])} +
, + title: 'Recruitment - overall', + }, + { + content: + json['recruitment']['overall'] + && json['recruitment']['overall']['total_recruitment'] > 0 ? + <> + { + updateFilters(formDataObj, 'siteBreakdown'); + }} + /> + {Object.keys(chartDetails['siteBreakdown']).map((chartID) => { + return showChart('siteBreakdown', chartID); + })} + : +

There have been no candidates registered yet.

, + title: 'Recruitment - site breakdown', + }, + { + content: <> - {siteBreakdown} + {Object.entries(json['recruitment']).map(([key, value]) => { + if (key !== 'overall') { + return
+ {progressBarBuilder(value)} +
; + } + })} , - title: 'Recruitment - site breakdown', - }, - { - content: - <> - {projectBreakdown} - , - title: 'Recruitment - project breakdown', - }, - ]} - /> + title: 'Recruitment - project breakdown', + }, + { + content: + <> + {Object.entries(json['recruitmentcohorts']) + .map(([key, value]) => { + return
+ {progressBarBuilder(value)} +
; + } + )} + , + title: 'Recruitment - cohort breakdown', + }, + ]} + /> + ); }; + Recruitment.propTypes = { data: PropTypes.object, + baseURL: PropTypes.string, + updateFilters: PropTypes.function, + showChart: PropTypes.function, }; Recruitment.defaultProps = { data: {}, diff --git a/modules/statistics/jsx/widgets/studyprogression.js b/modules/statistics/jsx/widgets/studyprogression.js index 8da62572a7b..a0964e31b1a 100644 --- a/modules/statistics/jsx/widgets/studyprogression.js +++ b/modules/statistics/jsx/widgets/studyprogression.js @@ -1,7 +1,9 @@ import React, {useEffect, useState} from 'react'; import PropTypes from 'prop-types'; import Loader from 'Loader'; -import Panel from 'jsx/Panel'; +import Panel from 'Panel'; +import {QueryChartForm} from './helpers/queryChartForm'; +import {setupCharts} from './helpers/chartBuilder'; /** * StudyProgression - a widget containing statistics for study data. @@ -11,78 +13,114 @@ import Panel from 'jsx/Panel'; */ const StudyProgression = (props) => { const [loading, setLoading] = useState(true); - const [siteScans, setSiteScans] = useState({}); - const [siteRecruitments, setSiteRecruitments] = useState({}); + + let json = props.data; + + const [chartDetails, setChartDetails] = useState({ + 'total_scans': { + 'scans_bymonth': { + sizing: 11, + title: 'Scan sessions per site', + filters: '', + chartType: 'line', + dataType: 'line', + label: 'Scans', + legend: 'under', + options: {line: 'line'}, + }, + }, + 'total_recruitment': { + 'siterecruitment_line': { + sizing: 11, + title: 'Recruitment per site', + filters: '', + chartType: 'line', + dataType: 'line', + legend: '', + options: {line: 'line'}, + }, + }, + }); + + const showChart = ((section, chartID) => { + return props.showChart(section, chartID, + chartDetails, setChartDetails); + }); /** * useEffect - modified to run when props.data updates. */ useEffect(() => { - const json = props.data; if (json && Object.keys(json).length !== 0) { - setSiteScans( - json['studyprogression']['total_scans'] > 0 - ?
-
Scan sessions per site
-
-
- - Note that the Recruitment and Study Progression charts -  include data from ineligible, excluded, and consent -  withdrawn candidates. - -
- :

There have been no scans yet.

- ); - setSiteRecruitments( - json['studyprogression']['recruitment']['overall'] - ['total_recruitment'] > 0 - ?
-
Recruitment per site
-
-
- - Note that the Recruitment and Study Progression charts -  include data from ineligible, excluded, and consent -  withdrawn candidates. - -
- :

There have been no candidates registered yet.

- ); + setupCharts(false, chartDetails).then((data) => { + setChartDetails(data); + }); + json = props.data; setLoading(false); } }, [props.data]); - /** - * Renders the React component. - * - * @return {JSX.Element} - React markup for component. - */ + const updateFilters = (formDataObj, section) => { + props.updateFilters(formDataObj, section, + chartDetails, setChartDetails); + }; + return loading ? : ( - - {siteScans} - , - title: 'Study Progression - site scans', - }, - { - content: <> - {siteRecruitments} - , - title: 'Study Progression - site recruitment', - }, - ]} - /> + <> + { + setupCharts(false, chartDetails); + }} + views={[ + { + content: json['studyprogression']['total_scans'] > 0 ? + <> + { + updateFilters(formDataObj, 'total_scans'); + }} + /> + {showChart('total_scans', 'scans_bymonth')} + : +

There have been no scans yet.

, + title: 'Study Progression - site scans', + }, + { + content: + json['studyprogression']['recruitment'] + ['overall']['total_recruitment'] + > 0 ? + <> + { + updateFilters(formDataObj, 'total_recruitment'); + }} + /> + {showChart('total_recruitment', 'siterecruitment_line')} + : +

There have been no candidates registered yet.

, + title: 'Study Progression - site recruitment', + }, + ]} + /> + ); }; StudyProgression.propTypes = { data: PropTypes.object, + baseURL: PropTypes.string, + updateFilters: PropTypes.function, + showChart: PropTypes.function, }; StudyProgression.defaultProps = { data: {}, diff --git a/modules/statistics/php/charts.class.inc b/modules/statistics/php/charts.class.inc index 4e542ad8f10..773d38d3dba 100644 --- a/modules/statistics/php/charts.class.inc +++ b/modules/statistics/php/charts.class.inc @@ -84,9 +84,14 @@ class Charts extends \NDB_Page if (count($pathparts) != 2) { return new \LORIS\Http\Response\JSON\NotFound(); } + switch ($pathparts[1]) { case 'siterecruitment_pie': return $this->_handleSitePieData(); + case 'agerecruitment_pie': + return $this->_handleAgePieData(); + case 'ethnicity_pie': + return $this->_handleEthnicityPieData(); case 'siterecruitment_bysex': return $this->_handleSiteSexBreakdown(); case 'scans_bymonth': @@ -105,20 +110,29 @@ class Charts extends \NDB_Page */ private function _handleSitePieData() { - $DB = \NDB_Factory::singleton()->database(); - - $recruitmentBySiteData = []; + $params = $this->_parseGetParameters(); $user = \NDB_Factory::singleton()->user(); $list_of_sites = $user->getStudySites(); + $conditions = $this->_buildQueryConditions($params); + + $DB = \NDB_Factory::singleton()->database(); + $recruitmentBySiteData = []; + foreach ($list_of_sites as $siteID => $siteName) { $totalRecruitment = $DB->pselectOne( "SELECT COUNT(c.CandID) FROM candidate c - WHERE - c.RegistrationCenterID=:Site AND - c.Active='Y' AND - c.Entity_type='Human'", + {$conditions['cohortJoin']} + {$conditions['participantStatusJoin']} + WHERE c.RegistrationCenterID=:Site + AND c.Active='Y' + AND c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']}", ['Site' => $siteID] ); @@ -137,32 +151,180 @@ class Charts extends \NDB_Page */ private function _handleSiteSexBreakdown() { - $DB = \NDB_Factory::singleton()->database(); - $sexData = []; + $params = $this->_parseGetParameters(); $user = \NDB_Factory::singleton()->user(); $list_of_sites = $user->getStudySites(); + $conditions = $this->_buildQueryConditions($params); + + $DB = \NDB_Factory::singleton()->database(); + $sexData = []; + foreach ($list_of_sites as $siteID => $siteName) { - $sexData['labels'][] = $siteName; - $sexData['datasets']['female'][] = $DB->pselectOne( + $female_data = $DB->pselectOne( "SELECT COUNT(c.CandID) FROM candidate c + {$conditions['cohortJoin']} + {$conditions['participantStatusJoin']} WHERE c.RegistrationCenterID=:Site - AND c.Sex='female' AND c.Active='Y' - AND c.Entity_type='Human'", + AND c.Sex='female' AND c.Active='Y' + AND c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']}", ['Site' => $siteID] ); - $sexData['datasets']['male'][] = $DB->pselectOne( + $male_data = $DB->pselectOne( "SELECT COUNT(c.CandID) FROM candidate c - WHERE c.RegistrationCenterID=:Site AND c.Sex='male' AND c.Active='Y' - AND c.Entity_type='Human'", + {$conditions['cohortJoin']} + {$conditions['participantStatusJoin']} + WHERE c.RegistrationCenterID=:Site + AND c.Sex='male' AND c.Active='Y' + AND c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']}", ['Site' => $siteID] ); + if ($male_data !== '0' && $female_data !== '0') { + $sexData['labels'][] = $siteName; + $sexData['datasets']['male'][] = $male_data; + $sexData['datasets']['female'][] = $female_data; + } } + return (new \LORIS\Http\Response\JsonResponse($sexData)); } + /** + * Handle an incoming request for age pie data. + * + * @return ResponseInterface + */ + private function _handleAgePieData() + { + $params = $this->_parseGetParameters(); + + $conditions = $this->_buildQueryConditions($params); + + $DB = \NDB_Factory::singleton()->database(); + + $dates = $DB->pselect( + "SELECT DISTINCT c.CandID, c.DoB, c.Date_registered + FROM candidate c + {$conditions['cohortJoin']} + {$conditions['participantStatusJoin']} + WHERE c.DoB IS NOT NULL + AND c.DoB <= c.date_registered + AND c.Active='Y' + AND c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']}", + [] + ); + + // Initialize an array to store the dynamic age splits + $ageSplits = []; + + foreach ($dates as $_ => $value) { + // Note that age is calculated from date registered, not today + $ageOb = \Utility::calculateAge( + $value['DoB'], + $value['Date_registered'] + ); + $age = $ageOb['year']; + + // Determine the starting point of the age split (a multiple of 5) + $startOfSplit = intval(floor($age / 5) * 5); + + // Check if the age split already exists in the array + if (!isset($ageSplits[$startOfSplit])) { + // If not, create a new entry with the starting age as the key + $ageSplits[$startOfSplit] = 0; + } + + // Increment the count for the corresponding age split + ++$ageSplits[$startOfSplit]; + } + + // Convert the dynamic age splits into the desired format + $recruitmentByAgeData = []; + foreach ($ageSplits as $startOfSplit => $count) { + $endOfSplit = $startOfSplit + 4; // Adjust the age range as needed + $label = $startOfSplit . '-' . $endOfSplit; + $recruitmentByAgeData[] = ["label" => $label, "total" => $count]; + } + + return (new \LORIS\Http\Response\JsonResponse($recruitmentByAgeData)); + } + + /** + * Handle an incoming request for ethnicity pie data. + * + * @return ResponseInterface + */ + private function _handleEthnicityPieData() + { + $params = $this->_parseGetParameters(); + + $conditions = $this->_buildQueryConditions($params); + + $DB = \NDB_Factory::singleton()->database(); + + $candidates = $DB->pselect( + "SELECT DISTINCT c.CandID, c.Ethnicity + FROM candidate c + {$conditions['cohortJoin']} + {$conditions['participantStatusJoin']} + WHERE c.Active='Y' + AND c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']}", + [] + ); + + // Initialize an array to store the ethnicities + $ethnicities = []; + + foreach ($candidates as $_ => $value) { + // Check if the ethnicity already exists in the array + if (!isset($ethnicities[$value["Ethnicity"]])) { + // If not, create a new entry with the ethnicity as the key + $ethnicities[$value["Ethnicity"]] = 0; + } + + // Increment the count for the corresponding ethnicity + ++$ethnicities[$value["Ethnicity"]]; + } + + // Convert into the desired format + $recruitmentByEthnicityData = []; + foreach ($ethnicities as $id => $count) { + $id = str_replace("_", " ", $id); + $id = strtolower($id); + $id = ucwords($id); + if ($id == null) { + $id = "Unknown"; + } + $label = $id; + $recruitmentByEthnicityData[] = ["label" => $label, "total" => $count]; + } + + return (new \LORIS\Http\Response\JsonResponse($recruitmentByEthnicityData)); + } + + /** * Handle an incoming request for monthly progression * @@ -170,8 +332,13 @@ class Charts extends \NDB_Page */ private function _handleScansByMonth() { - $DB = \NDB_Factory::singleton()->database(); + $params = $this->_parseGetParameters(); + $user = \NDB_Factory::singleton()->user(); + $list_of_sites = $user->getStudySites(); + + $conditions = $this->_buildQueryConditions($params, true); + $DB = \NDB_Factory::singleton()->database(); $scanData = []; // Run a query to get all the data. Order matters to ensure that the // labels are calculated in the correct order. @@ -182,13 +349,20 @@ class Charts extends \NDB_Page FROM files f LEFT JOIN parameter_file pf USING (FileID) LEFT JOIN session s ON (s.ID=f.SessionID) + {$conditions['participantStatusJoin']} JOIN parameter_type pt USING (ParameterTypeID) WHERE pt.Name='acquisition_date' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['visitQuery']} + {$conditions['siteQuery']} + {$conditions['participantStatusQuery']} GROUP BY MONTH(pf.Value), YEAR(pf.Value), s.CenterID, datelabel ORDER BY YEAR(pf.Value), MONTH(pf.Value), s.CenterID", [] ); + // TODO: make this work as bar data // Create the labels. // // We want to ensure that every month label appear exactly once and @@ -205,8 +379,6 @@ class Charts extends \NDB_Page $scanData['labels'] = array_keys($labels); // Massage the data into the appropriate format per site. - $user = \NDB_Factory::singleton()->user(); - $list_of_sites = $user->getStudySites(); foreach ($list_of_sites as $siteID => $siteName) { $scanData['datasets'][] = [ "name" => $siteName, @@ -263,18 +435,30 @@ class Charts extends \NDB_Page */ private function _handleSiteLineData() { + $params = $this->_parseGetParameters(); + $user = \NDB_Factory::singleton()->user(); + $list_of_sites = $user->getStudySites(); + + $conditions = $this->_buildQueryConditions($params); + $DB = \NDB_Factory::singleton()->database(); - $recruitmentData = []; - $recruitmentStartDate = $DB->pselectOne( - "SELECT MIN(Date_registered) FROM candidate", - [] - ); - $recruitmentEndDate = $DB->pselectOne( - "SELECT MAX(Date_registered) FROM candidate", + $recruitmentData = []; + $recruitmentBound = $DB->pselect( + "SELECT MIN(c.Date_registered), MAX(c.Date_registered) + FROM candidate c + {$conditions['cohortJoin']} + WHERE c.Entity_type='Human' + {$conditions['projectQuery']} + {$conditions['cohortQuery']} + {$conditions['siteQuery']} + {$conditions['visitQuery']}", [] ); + $recruitmentStartDate = $recruitmentBound[0]['MIN(c.Date_registered)']; + $recruitmentEndDate = $recruitmentBound[0]['MAX(c.Date_registered)']; + if ($recruitmentStartDate !== null && $recruitmentEndDate !== null ) { @@ -282,26 +466,154 @@ class Charts extends \NDB_Page new \DateTimeImmutable($recruitmentStartDate), new \DateTimeImmutable($recruitmentEndDate) ); + } else { + $recruitmentData['labels'] = []; } - $user = \NDB_Factory::singleton()->user(); - $list_of_sites = $user->getStudySites(); - foreach ($list_of_sites as $siteID => $siteName) { - if (!isset($recruitmentData['labels'])) { - continue; - } $recruitmentData['datasets'][] = [ "name" => $siteName, "data" => $this->_getSiteLineRecruitmentData( $siteID, $recruitmentData['labels'], + $conditions['cohortJoin'], + $conditions['participantStatusJoin'], + $conditions['cohortQuery'], + $conditions['projectQuery'], + $conditions['participantStatusQuery'] ), ]; } return new \LORIS\Http\Response\JsonResponse($recruitmentData); } + /** + * Helper to parse the GET parameters from the incoming request. + * + * @return array + */ + private function _parseGetParameters() + { + $selectedProjects = empty($_GET['selectedProjects']) + || $_GET['selectedProjects'] === 'null' + || $_GET['selectedProjects'] === 'undefined' + ? null : explode(",", $_GET['selectedProjects']); + $selectedCohorts = empty($_GET['selectedCohorts']) + || $_GET['selectedCohorts'] === 'null' + || $_GET['selectedCohorts'] === 'undefined' + ? null : explode(",", $_GET['selectedCohorts']); + $selectedSites = empty($_GET['selectedSites']) + || $_GET['selectedSites'] === 'null' + || $_GET['selectedSites'] === 'undefined' + ? null : explode(",", $_GET['selectedSites']); + $selectedVisits = empty($_GET['selectedVisits']) + || $_GET['selectedVisits'] === 'null' + || $_GET['selectedVisits'] === 'undefined' + ? null : explode(",", $_GET['selectedVisits']); + $selectedParticipantStatus = empty( + $_GET['selectedParticipantStatus'] + ) + || $_GET['selectedParticipantStatus'] === 'null' + || $_GET['selectedParticipantStatus'] === 'undefined' ? null + : explode( + ",", + $_GET['selectedParticipantStatus'] + ); + + return [ + 'selectedProjects' => $selectedProjects, + 'selectedCohorts' => $selectedCohorts, + 'selectedSites' => $selectedSites, + 'selectedVisits' => $selectedVisits, + 'selectedParticipantStatus' => $selectedParticipantStatus, + ]; + } + + /** + * Helper to generate query conditions for for incoming requests. + * + * @param array $params The parameters from the incoming request. + * @param bool $scansbymonth Whether or not the request is for scans by month. + * + * @return array + */ + private function _buildQueryConditions( + $params, + $scansbymonth = false + ) { + $user = \NDB_Factory::singleton()->user(); + + $projectQuery = ''; + $cohortQuery = ''; + $cohortJoin = ''; + $visitQuery = ''; + $PSJoin = ''; + $participantStatusQuery = ''; + + if (!is_null($params['selectedProjects'])) { + $projectString = "'" . implode("','", $params['selectedProjects']) . "'"; + $projectQuery = " AND c.RegistrationProjectID IN ({$projectString}) "; + } + + if (!is_null($params['selectedCohorts'])) { + $cohortString = "'" . implode("','", $params['selectedCohorts']) . "'"; + $cohortQuery = " AND s.CohortID IN ({$cohortString}) "; + $cohortJoin = "JOIN session s ON s.CandID=c.CandID"; + } + if (!is_null($params['selectedSites'])) { + // Set site query if selected + $siteString = "'" . implode("','", $params['selectedSites']) . "'"; + $siteQuery = " AND c.RegistrationCenterID IN ({$siteString}) "; + if ($scansbymonth === true) { + $siteQuery = " AND s.CenterID IN ({$siteString}) "; + } + } else { + // If not selected, only take user sites + $centerIDs = $user->getCenterIDs(); + $centerList = "'" . implode("','", $centerIDs) . "'"; + $siteQuery = " AND c.RegistrationCenterID IN ({$centerList}) "; + if ($scansbymonth === true) { + $siteQuery = " AND s.CenterID IN ({$centerList}) "; + } + } + if (!is_null($params['selectedVisits'])) { + // Set visit query if visits selected + $visitString = "'" . implode("','", $params['selectedVisits']) . "'"; + $visitQuery = " AND s.Visit_label IN ({$visitString}) "; + // since they are the same, if visits are selected, + // then the cohort string is overwritten + $cohortJoin = "JOIN session s ON s.CandID=c.CandID"; + } + if (!is_null($params['selectedParticipantStatus'])) { + $PSJoin = 'LEFT JOIN participant_status ps ON c.CandID=ps.CandID'; + $participantStatusString = "'" . implode( + "','", + $params['selectedParticipantStatus'] + ) . "'"; + // Null participant status counts as Active because + // sometimes users do not update the participant_status tab + if (in_array('1', $params['selectedParticipantStatus'])) { + $participantStatusQuery = " AND ( + ps.participant_status IN ({$participantStatusString}) + OR ps.participant_status IS NULL + )"; + } else { + $participantStatusQuery = " AND ps.participant_status + IN ({$participantStatusString}) "; + } + } + + return [ + 'projectQuery' => $projectQuery, + 'cohortQuery' => $cohortQuery, + 'cohortJoin' => $cohortJoin, + 'visitQuery' => $visitQuery, + 'siteQuery' => $siteQuery, + 'participantStatusJoin' => $PSJoin, + 'participantStatusQuery' => $participantStatusQuery, + ]; + } + /** * Helper to generate labels for every month between startDate and endDate. * @@ -333,27 +645,50 @@ class Charts extends \NDB_Page /** * Helper to generate the data for the site recruitment line for $siteID. * - * @param int $siteID The centerID to get data for. - * @param array $labels The list of labels on the chart to fill the data for. + * @param int $siteID The centerID to get data for. + * @param array $labels The list of labels on the chart + * to fill the data for. + * @param String $cohortJoin The join statement for cohort if needed + * @param String $PSJoin The join statement for + * participantStatusJoin + * if needed + * @param String $cohortQuery The where statement for cohort if needed + * @param String $projectQuery The where statement + * for project if needed + * @param String $participantStatusQuery The where statement for + * participantStatus if needed * * @return array */ - private function _getSiteLineRecruitmentData($siteID, $labels) - { + private function _getSiteLineRecruitmentData( + $siteID, + $labels, + $cohortJoin, + $PSJoin, + $cohortQuery, + $projectQuery, + $participantStatusQuery + ) { $DB = \NDB_Factory::singleton()->database(); $data = []; foreach ($labels as $label) { - $month = (strlen($label) == 6) + $month = (strlen($label) == 6) ? substr($label, 0, 1) : substr($label, 0, 2); - $year = substr($label, -4, 4); + $year = substr($label, -4, 4); + $data[] = $DB->pselectOne( "SELECT COUNT(c.CandID) FROM candidate c + {$cohortJoin} + {$PSJoin} WHERE c.RegistrationCenterID=:Site AND MONTH(c.Date_registered)=:Month AND YEAR(c.Date_registered)=:Year - AND c.Entity_type='Human'", + AND c.Entity_type='Human' + {$cohortQuery} + {$projectQuery} + {$participantStatusQuery}", [ 'Site' => $siteID, 'Month' => $month, diff --git a/modules/statistics/php/widgets.class.inc b/modules/statistics/php/widgets.class.inc index 9afb6d07466..7d53c797a97 100644 --- a/modules/statistics/php/widgets.class.inc +++ b/modules/statistics/php/widgets.class.inc @@ -76,38 +76,201 @@ class Widgets extends \NDB_Page implements ETagCalculator ) ]; - $projects = \Utility::getProjectList(); - foreach (array_keys($projects) as $projectID) { - $projectInfo = $config->getProjectSettings($projectID); + $user = $factory->user(); + $projects = $user->getProjectIDs(); + + $projectOptions = []; + $cohortOptions = []; + $visitOptions = []; + $recruitmentCohorts = []; + foreach ($projects as $projectID) { + // Set project recruitment data + $projectInfo = $config->getProjectSettings(intval(strval($projectID))); if (is_null($projectInfo)) { throw new \LorisException( 'No project settings exist in the Database for ' . - 'project ID ' . intval($projectID) + 'project ID ' . intval(strval($projectID)) ); } - $recruitment[$projectID] = $this->_createProjectProgressBar( - $projectID, - $projectInfo['Name'], - $projectInfo['recruitmentTarget'], - $this->getTotalRecruitmentByProject($db, $projectID), - $db - ); + $recruitment[intval(strval($projectID))] + = $this->_createProjectProgressBar( + strval($projectID), + $projectInfo['Name'], + $projectInfo['recruitmentTarget'], + $this->getTotalRecruitmentByProject($db, $projectID), + $db + ); + + // Set cohort recruitment data + $project = \Project::getProjectFromID($projectID); + $cohorts = $project->getCohorts(); + + $projectOptions[strval($projectID)] = $project->getName(); + foreach ($cohorts AS $sp) { + $cohortOptions[$sp["cohortId"]] = $sp["title"]; + $recruitmentCohorts[$sp["cohortId"]] + = $this->_createCohortProgressBar( + $sp["cohortId"], + $sp["title"], + $sp["recruitmentTarget"], + $db + ); + $cohortVisits = \Utility::getVisitsForCohort( + intval($sp["cohortId"]) + ); + foreach ($cohortVisits as $visit) { + $visitOptions[$visit] = $cohortVisits[$visit]; + } + } } - $values = []; + $sites = \Utility::getSiteList(); + $userCenters = $user->getCenterIDs(); + + $siteOptions = array_intersect_key($sites, $userCenters); + + $participantStatusOptions + = \Candidate::getParticipantStatusOptions(); + + $options = [ + 'projects' => $projectOptions, + 'cohorts' => $cohortOptions, + 'sites' => $siteOptions, + 'visits' => $visitOptions, + 'participantStatus' => $participantStatusOptions + ]; + $values = []; // Used for the react widget recruitment.js $values['recruitment'] = $recruitment; // Used for the react widget studyprogression.js - $values['studyprogression'] = [ + $values['studyprogression'] = [ 'total_scans' => $totalScans, 'recruitment' => $recruitment ]; + $values['options'] = $options; + $values['recruitmentcohorts'] = $recruitmentCohorts; $this->_cache = new \LORIS\Http\Response\JsonResponse($values); return $this->_cache; } + /** + * Generates the template data for a progress bar. + * + * @param $ID The name of the progress bar being + * created. + * @param $title The title to add to the template variables. + * @param $recruitmentTarget The target for this recruitment type. + * @param \Database $db The database connection to get data from. + * + * @return array Smarty template data + */ + private function _createCohortProgressBar( + $ID, + $title, + $recruitmentTarget, + \Database $db + ) { + + $user = \User::singleton(); + $projectIDs = $user->getProjectIDs(); + $projectList = "'" . implode("','", $projectIDs) . "'"; + $centerIDs = $user->getCenterIDs(); + $centerList = "'" . implode("','", $centerIDs) . "'"; + + $totalRecruitment = intval( + $db->pselectOne( + "SELECT COUNT(DISTINCT s.CandID) + FROM session s JOIN candidate c + WHERE s.CohortID=:sid + AND s.CenterID IN ({$centerList}) + AND s.ProjectID IN ({$projectList}) + AND c.RegistrationCenterID <> 1", + ['sid' => $ID] + ) + ); + + $rv = [ + 'total_recruitment' => $totalRecruitment, + 'title' => $title, + ]; + + if (empty($recruitmentTarget)) { + $recruitmentTarget = $totalRecruitment; + } + + $rv['recruitment_target'] = $recruitmentTarget; + $totalFemales = $this->getTotalSexByCohort( + $db, + "Female", + intval($ID) + ); + $rv['female_total'] = $totalFemales; + $rv['female_percent'] = $recruitmentTarget ? + round($totalFemales / $recruitmentTarget * 100) : + null; + $totalMales = $this->getTotalSexByCohort( + $db, + "Male", + intval($ID) + ); + $rv['male_total'] = $totalMales; + $rv['male_percent'] = $recruitmentTarget ? + round($totalMales / $recruitmentTarget * 100) : + null; + if ($totalRecruitment > $recruitmentTarget) { + $rv['surpassed_recruitment'] = "true"; + + $rv['female_full_percent'] = $totalRecruitment ? + round($totalFemales / $totalRecruitment * 100) : + null; + + $rv['male_full_percent'] = $totalRecruitment ? + round($totalMales / $totalRecruitment * 100) : + null; + } + return $rv; + } + + /** + * Gets the total count of candidates of a specific sex, + * associated with a specific project + * + * @param \Database $DB A database connection to retrieve information + * from. + * @param string $sex A biological sex (male or female) + * @param int $cohortID Cohort ID + * + * @return int|string + */ + function getTotalSexByCohort(\Database $DB, string $sex, int $cohortID) + { + + $user = \User::singleton(); + $projectIDs = $user->getProjectIDs(); + $projectList = "'" . implode("','", $projectIDs) . "'"; + $centerIDs = $user->getCenterIDs(); + $centerList = "'" . implode("','", $centerIDs) . "'"; + + return intval( + $DB->pselectOne( + "SELECT COUNT(DISTINCT s.CandID) + FROM session s + JOIN candidate c ON c.CandID=s.CandID + WHERE CohortID=:sid + AND CenterID IN ({$centerList}) + AND ProjectID IN ({$projectList}) + AND c.Sex=:sex + AND c.RegistrationCenterID <> 1", + [ + 'sex' => $sex, + 'sid' => $cohortID + ] + ) + ); + } + /** * Gets the total count of candidates associated with a specific project * @@ -158,7 +321,11 @@ class Widgets extends \NDB_Page implements ETagCalculator if ($ID == 'overall') { $totalFemales = $this->_getTotalSex($db, "Female"); } else { - $totalFemales = $this->getTotalSexByProject($db, "Female", intval($ID)); + $totalFemales = $this->getTotalSexByProject( + $db, + "Female", + intval(strval($ID)) + ); } $rv['female_total'] = $totalFemales; $rv['female_percent'] @@ -166,7 +333,11 @@ class Widgets extends \NDB_Page implements ETagCalculator if ($ID == 'overall') { $totalMales = $this->_getTotalSex($db, "Male"); } else { - $totalMales = $this->getTotalSexByProject($db, "Male", intval($ID)); + $totalMales = $this->getTotalSexByProject( + $db, + "Male", + intval(strval($ID)) + ); } $rv['male_total'] = $totalMales; $rv['male_percent'] @@ -219,7 +390,9 @@ class Widgets extends \NDB_Page implements ETagCalculator return $DB->pselectOneInt( "SELECT COUNT(c.CandID) FROM candidate c - WHERE c.Sex=:sex AND c.Active='Y' AND c.RegistrationProjectID=:PID + WHERE c.Sex=:sex + AND c.Active='Y' + AND c.RegistrationProjectID=:PID AND c.Entity_type='Human' AND c.RegistrationCenterID <> 1", [ 'sex' => $sex, @@ -231,14 +404,15 @@ class Widgets extends \NDB_Page implements ETagCalculator /** * Gets the total count of candidates associated with a specific project. * - * @param \Database $db A database connection to retrieve information - * from. - * @param int $projectID The Project ID to get recruitment for. + * @param \Database $db A database connection to retrieve information + * from. + * @param \ProjectID $projectID The Project ID to get recruitment for. * * @return int */ - function getTotalRecruitmentByProject(\Database $db, int $projectID): int + function getTotalRecruitmentByProject(\Database $db, \ProjectID $projectID): int { + $projectID = intval(strval($projectID)); return $db->pselectOneInt( "SELECT COUNT(*) FROM candidate c