Skip to content

Commit 48d4b42

Browse files
committed
transferring widget.js file from main TMD repo
0 parents  commit 48d4b42

File tree

2 files changed

+345
-0
lines changed

2 files changed

+345
-0
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Training Metrics Databse widgets
2+
3+
This is the github repo for the official TMD widgets and user instructions
4+
5+
## Summary statistics widget
6+

widget.js

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
(function (global) {
2+
/**
3+
* Base endpoint for question set summary statistics.
4+
* Consumers can override this by providing a full `endpoint` option.
5+
*/
6+
const DEFAULT_BASE_URL = 'https://tmd.elixir-europe.org/metrics/set/';
7+
const CHART_JS_CDN = 'https://cdn.jsdelivr.net/npm/chart.js';
8+
9+
/**
10+
* Default configuration for the widget. Settings can be overridden via the `TMDWidget` factory function.
11+
*/
12+
const DEFAULT_OPTIONS = {
13+
questionSets: null,
14+
questions: null,
15+
chartType: 'pie',
16+
dataScope: 'all',
17+
endpoint: null,
18+
colors: null,
19+
};
20+
21+
/**
22+
* Resolve a DOM node from a selector string or return the node if one is provided.
23+
*
24+
* @param {string | HTMLElement} target
25+
* @returns {HTMLElement | null}
26+
*/
27+
function resolveContainer(target) {
28+
if (!target) {
29+
return null;
30+
}
31+
if (typeof target === 'string') {
32+
return document.querySelector(target);
33+
}
34+
if (target instanceof HTMLElement) {
35+
return target;
36+
}
37+
return null;
38+
}
39+
40+
/**
41+
* Normalise the API payload into an array of questions with aggregated counts.
42+
*
43+
* @param {unknown} payload
44+
* @returns {Array<{ id: string, label: string, aggregated: Record<string, number> }>}
45+
*/
46+
function normalisePayload(payload) {
47+
const values = Array.isArray(payload?.values) ? payload.values : [];
48+
return values
49+
.filter((entry) => entry?.id)
50+
.map((entry) => {
51+
const id = entry.id;
52+
const options = Array.isArray(entry?.options) ? entry.options : [];
53+
const aggregated = options
54+
.filter((option) => option?.id)
55+
.reduce((map, option) => {
56+
const optionLabel = option.id;
57+
const count = Number(option?.count ?? 0);
58+
map[optionLabel] = Number.isFinite(count) ? count : 0;
59+
return map;
60+
}, {});
61+
62+
return {
63+
id,
64+
label: entry?.label || entry?.title || id,
65+
aggregated,
66+
};
67+
});
68+
}
69+
70+
/**
71+
* Default colourway borrowed from Plotly (used by the in-app reports) so the widget matches
72+
* the styling on `/report/set/<question-set>`.
73+
*/
74+
const DEFAULT_COLORWAY = [
75+
'#1f77b4',
76+
'#ff7f0e',
77+
'#2ca02c',
78+
'#d62728',
79+
'#9467bd',
80+
'#8c564b',
81+
'#e377c2',
82+
'#7f7f7f',
83+
'#bcbd22',
84+
'#17becf',
85+
];
86+
87+
/**
88+
* Cycle through the Plotly colourway to generate chart colours.
89+
*
90+
* @param {number} size
91+
* @param {string[] | null} overrideColors
92+
* @returns {string[]}
93+
*/
94+
function buildPalette(size, overrideColors) {
95+
if (size <= 0) {
96+
return [];
97+
}
98+
99+
const source =
100+
Array.isArray(overrideColors) && overrideColors.length > 0
101+
? overrideColors
102+
: DEFAULT_COLORWAY;
103+
104+
const palette = [];
105+
for (let index = 0; index < size; index += 1) {
106+
palette.push(source[index % source.length]);
107+
}
108+
return palette;
109+
}
110+
111+
let chartJsLoader = null;
112+
113+
function buildRequestUrl(settings) {
114+
const base = settings.endpoint || DEFAULT_BASE_URL;
115+
116+
let url;
117+
try {
118+
url = new URL(base, base.startsWith('http') ? undefined : global.location?.origin);
119+
} catch (error) {
120+
return base;
121+
}
122+
123+
const hasQuestionSets = Array.isArray(settings.questionSets) && settings.questionSets.length > 0;
124+
const hasQuestions = Array.isArray(settings.questions) && settings.questions.length > 0;
125+
126+
// Rule: questionSets take precedence if both exist; when both are missing request all data
127+
if (hasQuestionSets) {
128+
url.searchParams.set('question_sets', settings.questionSets.join(','));
129+
} else if (hasQuestions) {
130+
url.searchParams.set('questions', settings.questions.join(','));
131+
}
132+
133+
134+
// Include data scope
135+
if (settings.dataScope && !url.searchParams.has('data_scope')) {
136+
url.searchParams.set('data_scope', settings.dataScope);
137+
}
138+
139+
return url.toString();
140+
}
141+
142+
/**
143+
* Ensure Chart.js is available globally, loading it from the CDN if necessary.
144+
*
145+
* @returns {Promise<void>}
146+
*/
147+
function ensureChartJs() {
148+
if (typeof global.Chart !== 'undefined') {
149+
return Promise.resolve();
150+
}
151+
152+
if (!chartJsLoader) {
153+
chartJsLoader = new Promise((resolve, reject) => {
154+
const existingScript = document.querySelector(`script[src="${CHART_JS_CDN}"]`);
155+
if (existingScript) {
156+
existingScript.addEventListener('load', () => resolve());
157+
existingScript.addEventListener('error', () =>
158+
reject(new Error('Failed to load Chart.js'))
159+
);
160+
return;
161+
}
162+
163+
const script = document.createElement('script');
164+
script.src = CHART_JS_CDN;
165+
script.async = true;
166+
script.onload = () => resolve();
167+
script.onerror = () => reject(new Error('Failed to load Chart.js'));
168+
document.head.appendChild(script);
169+
});
170+
}
171+
172+
return chartJsLoader.then(() => {
173+
if (typeof global.Chart === 'undefined') {
174+
throw new Error('Chart.js failed to initialise.');
175+
}
176+
});
177+
}
178+
179+
/**
180+
* Render a single question chart inside the container.
181+
*
182+
* @param {HTMLElement} container
183+
* @param {{ id: string, label: string, aggregated: Record<string, number> }} question
184+
* @param {'bar' | 'pie'} chartType
185+
* @param {string[] | null} colors
186+
* @returns {Chart}
187+
*/
188+
function renderQuestionChart(container, question, chartType, colors) {
189+
const labels = Object.keys(question.aggregated);
190+
const values = labels.map((label) => question.aggregated[label]);
191+
192+
const wrapper = document.createElement('section');
193+
wrapper.className = 'tmd-widget-question';
194+
195+
const heading = document.createElement('h2');
196+
heading.className = 'tmd-widget-question__title';
197+
heading.textContent = question.label;
198+
wrapper.appendChild(heading);
199+
200+
const canvasWrapper = document.createElement('div');
201+
canvasWrapper.className = 'tmd-widget-chart';
202+
canvasWrapper.style.minHeight = chartType === 'pie' ? '320px' : '360px';
203+
const canvas = document.createElement('canvas');
204+
canvasWrapper.appendChild(canvas);
205+
wrapper.appendChild(canvasWrapper);
206+
container.appendChild(wrapper);
207+
208+
const palette = buildPalette(labels.length, colors);
209+
const isPie = chartType === 'pie';
210+
211+
return new Chart(canvas.getContext('2d'), {
212+
type: chartType,
213+
data: {
214+
labels,
215+
datasets: [
216+
{
217+
label: question.label,
218+
data: values,
219+
backgroundColor: palette,
220+
borderWidth: isPie ? 0 : 1,
221+
borderColor: isPie ? undefined : 'rgba(0, 0, 0, 0.1)',
222+
},
223+
],
224+
},
225+
options: {
226+
responsive: true,
227+
maintainAspectRatio: false,
228+
plugins: {
229+
legend: {
230+
display: true,
231+
position: 'bottom',
232+
},
233+
},
234+
scales: isPie
235+
? {}
236+
: {
237+
y: {
238+
beginAtZero: true,
239+
ticks: {
240+
precision: 0,
241+
},
242+
title: {
243+
display: true,
244+
text: 'Responses',
245+
},
246+
},
247+
x: {
248+
ticks: {
249+
maxRotation: 45,
250+
minRotation: 0,
251+
autoSkip: false,
252+
},
253+
},
254+
},
255+
},
256+
});
257+
}
258+
259+
/**
260+
* Clear previous widget state, destroy Chart instances, and show a message.
261+
*
262+
* @param {HTMLElement} container
263+
* @param {string} message
264+
*/
265+
function showMessage(container, message) {
266+
if (Array.isArray(container._tmdCharts)) {
267+
container._tmdCharts.forEach((chartInstance) => chartInstance.destroy());
268+
}
269+
container._tmdCharts = [];
270+
container.innerHTML = '';
271+
272+
const paragraph = document.createElement('p');
273+
paragraph.className = 'tmd-widget-message';
274+
paragraph.textContent = message;
275+
container.appendChild(paragraph);
276+
}
277+
278+
/**
279+
* Entry point exposed globally.
280+
*
281+
* @param {Object} options
282+
* @param {string | HTMLElement} options.container - Selector or element that will host the widget.
283+
* @param {'all' | 'node'} [options.dataScope] - Scope of data to request; currently only `all` is supported.
284+
* @param {string[]} [options.questionSets] - Optional list of question sets to request (passed through to the API).
285+
* @param {string[]} [options.questions] - Optional list of question slugs to request (passed through to the API).
286+
* @param {'bar' | 'pie'} [options.chartType] - Desired chart type.
287+
* @param {string} [options.endpoint] - Full endpoint override; otherwise derived from question set.
288+
* @param {string[]} [options.colors] - Optional array of CSS colour strings applied cyclically to chart segments.
289+
* @returns {Promise<void>}
290+
*/
291+
async function TMDWidget(options) {
292+
await ensureChartJs();
293+
294+
const settings = { ...DEFAULT_OPTIONS, ...options };
295+
const container = resolveContainer(settings.container);
296+
if (!container) {
297+
throw new Error('TMDWidget requires a valid container element.');
298+
}
299+
300+
if (!settings.endpoint && !Array.isArray(settings.questionSets) && !Array.isArray(settings.questions)) {
301+
console.warn('TMDWidget: no question filters provided; returning all available data.');
302+
}
303+
304+
showMessage(container, 'Loading metrics…');
305+
306+
const requestUrl = buildRequestUrl(settings);
307+
308+
let response;
309+
try {
310+
response = await fetch(requestUrl, { credentials: 'omit' });
311+
} catch (error) {
312+
showMessage(container, 'Unable to reach the Training Metrics Database.');
313+
throw error;
314+
}
315+
316+
if (!response.ok) {
317+
showMessage(container, 'Failed to load metrics data.');
318+
throw new Error(`TMDWidget request failed with status ${response.status}`);
319+
}
320+
321+
const payload = await response.json();
322+
const questions = normalisePayload(payload);
323+
if (questions.length === 0) {
324+
showMessage(container, 'No metrics available for the requested configuration.');
325+
return;
326+
}
327+
328+
if (Array.isArray(container._tmdCharts)) {
329+
container._tmdCharts.forEach((chartInstance) => chartInstance.destroy());
330+
}
331+
container.innerHTML = '';
332+
333+
container._tmdCharts = questions.map((question) =>
334+
renderQuestionChart(container, question, settings.chartType, settings.colors)
335+
);
336+
}
337+
338+
global.TMDWidget = TMDWidget;
339+
})(window);

0 commit comments

Comments
 (0)