ns = Inputs.text().classList[0]
// custom css to override some ojs defaults for inputs
html`<style>
.${ns} {
--label-width: 80px;
}
form.${ns} {
flex-wrap: wrap;
}
.plot-inputs form.${ns} {
flex-direction: column;
}
.${ns} div label {
background-color: #f4f4f4;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
margin-right: 0.25rem;
margin-bottom: 0.25rem;
width: auto;
}
.${ns} div label:hover,
.${ns} div label:active,
.${ns} div label:focus {
background-color: #fbe4b4;
}
.${ns} input[type="checkbox"] {
accent-color: black;
margin-bottom: 0;
}
.${ns} div input[type="number"] {
background-color: #f4f4f4;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
flex-shrink: 3;
border: none;
}
.${ns} select {
background-color: #f4f4f4;
border: none;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
//width: auto;
}
.${ns} .hist {
width: 100%;
display: flex;
flex-direction: column;
row-gap: 0em;
}
</style>`tol = ({
QualBright: ['#4477AA', '#EE6677', '#228833', '#CCBB44', '#66CCEE','#AA3377'],
QualHighContrast: ['#004488', '#DDAA33', '#BB5566'],
QualVibrant: ['#EE7733', '#0077BB', '#33BBEE', '#EE3377', '#CC3311', '#009988'],
QualMuted: ['#CC6677', '#332288', '#DDCC77', '#117733', '#88CCEE','#882255', '#44AA99', '#999933', '#AA4499'],
QualLight: ['#77AADD', '#EE8866', '#EEDD88', '#FFAABB', '#99DDFF', '#44BB99', '#BBCC33', '#AAAA00'],
Sunset: ['#364B9A', '#4A7BB7', '#6EA6CD', '#98CAE1', '#C2E4EF', '#EAECCC', '#FEDA8B', '#FDB366', '#F67E4B', '#DD3D2D', '#A50026'],
BuRd: ['#2166AC', '#4393C3', '#92C5DE', '#D1E5F0', '#F7F7F7', '#FDDBC7', '#F4A582', '#D6604D', '#B2182B'],
PRGn: ['#762A83', '#9970AB', '#C2A5CF', '#E7D4E8', '#F7F7F7', '#D9F0D3', '#ACD39E', '#5AAE61', '#1B7837'],
YlOrBr: ['#FFFFE5', '#FFF7BC', '#FEE391', '#FEC44F', '#FB9A29',
'#EC7014', '#CC4C02', '#993404', '#662506'],
Iridescent: ['#FEFBE9', '#FCF7D5', '#F5F3C1', '#EAF0B5', '#DDECBF',
'#D0E7CA', '#C2E3D2', '#B5DDD8', '#A8D8DC', '#9BD2E1',
'#8DCBE4', '#81C4E7', '#7BBCE7', '#7EB2E4', '#88A5DD',
'#9398D2', '#9B8AC4', '#9D7DB2', '#9A709E', '#906388',
'#805770', '#684957', '#46353A'],
RainbowPuRd: ['#6F4C9B', '#6059A9', '#5568B8', '#4E79C5', '#4D8AC6',
'#4E96BC', '#549EB3', '#59A5A9', '#60AB9E', '#69B190',
'#77B77D', '#8CBC68', '#A6BE54', '#BEBC48', '#D1B541',
'#DDAA3C', '#E49C39', '#E78C35', '#E67932', '#E4632D',
'#DF4828', '#DA2222'],
RainbowPuBr: ['#6F4C9B', '#6059A9', '#5568B8', '#4E79C5', '#4D8AC6',
'#4E96BC', '#549EB3', '#59A5A9', '#60AB9E', '#69B190',
'#77B77D', '#8CBC68', '#A6BE54', '#BEBC48', '#D1B541',
'#DDAA3C', '#E49C39', '#E78C35', '#E67932', '#E4632D',
'#DF4828', '#DA2222', '#B8221E', '#95211B', '#721E17',
'#521A13'],
RainbowWhRd: ['#E8ECFB', '#DDD8EF', '#D1C1E1', '#C3A8D1', '#B58FC2',
'#A778B4', '#9B62A7', '#8C4E99', '#6F4C9B', '#6059A9',
'#5568B8', '#4E79C5', '#4D8AC6', '#4E96BC', '#549EB3',
'#59A5A9', '#60AB9E', '#69B190', '#77B77D', '#8CBC68',
'#A6BE54', '#BEBC48', '#D1B541', '#DDAA3C', '#E49C39',
'#E78C35', '#E67932', '#E4632D', '#DF4828', '#DA2222'],
RainbowDiscrete: ['#E8ECFB', '#D9CCE3', '#D1BBD7', '#CAACCB', '#BA8DB4',
'#AE76A3', '#AA6F9E', '#994F88', '#882E72', '#1965B0',
'#437DBF', '#5289C7', '#6195CF', '#7BAFDE', '#4EB265',
'#90C987', '#CAE0AB', '#F7F056', '#F7CB45', '#F6C141',
'#F4A736', '#F1932D', '#EE8026', '#E8601C', '#E65518',
'#DC050C', '#A5170E', '#72190E', '#42150A']
})function dropdownInput(config = {}) {
//set up configuration
let {
inputId,
inputLabel,
placeholderText,
options,
selected,
} = config;
const width = '250px'
const maxHeight = '250px'
//dropdown button
const dropdownButton = Inputs.button(html`<div class='button-inner' style='display:flex;justify-content:space-between;height:100%;' id='dropdown-${inputId}'><div class='filter-selected' style='text-align:left;'>${placeholderText}</div><span style='font-size:10px;padding-left:5px;display:flex;align-items:center;'><i class="fas fa-chevron-down"></i></span></div>`);
dropdownButton.classList.add("dropdown-button");
const optionValues = options.map(option => option.value)
//selectInput
const selectInput = Inputs.select(optionValues, {multiple:true, value:selected});
selectInput.classList.add("dropdown-select");
selectInput.style.display="none";
//select and deselect buttons
const selectDeselectButtons = Inputs.button([["Select All", value=> "Select All"],["Deselect All", value => "Deselect All"]]);
selectDeselectButtons.classList.add("dropdown-action-buttons")
let selectInputValue = selected.slice(); // Create a copy of selected for selectInputValue
let optionListItems = options.map(option => {
const isSelected = selectInputValue.includes(option.value);
const listItem = html`<li id='${option.value}'><a style='cursor:pointer;'><span class='text'>${option.label}</span><span class='checkmark'><i class="fas fa-check"></i></span></a></li>`;
// Event listener for the list item
listItem.addEventListener('click', (event) => {
event.preventDefault();
const clickedListItem = event.currentTarget;
const clickedItemId = clickedListItem.id;
const index = selectInputValue.indexOf(clickedItemId);
if (clickedListItem.classList.contains('selected')) {
clickedListItem.classList.remove('selected');
if (index !== -1) {
selectInputValue.splice(index, 1);
}
} else {
clickedListItem.classList.add('selected');
selectInputValue.push(option.value);
}
// Update selectInput value
selectInput.value = selectInputValue;
// Update form value with selectInputValue
form.value = selectInputValue;
form.dispatchEvent(new CustomEvent("input", { bubbles: true }));
});
return listItem;
});
const optionList = html`<ul class='dropdown-list'>${optionListItems}</ul>`
const innerDropdown = html`<div class='dropdown-action-buttons-container'>${selectDeselectButtons}</div>${selectInput} <div class='dropdown-list-container' style='max-height:${maxHeight};overflow-y:scroll;'>${optionList}</div>`
innerDropdown.classList.add("dropdown-inner")
innerDropdown.style.display = "none";
innerDropdown.style.width = width;
// Function to toggle dropdown visibility
const toggleDropdown = () => {
innerDropdown.style.display = innerDropdown.style.display === "none" ? "block" : "none";
};
// Toggle the visibility of the select input on button click
dropdownButton.onclick = (event) => {
toggleDropdown();
};
optionListItems.forEach((t, index) => {
const option = options[index].value;
if (selectInputValue.includes(option)) {
t.classList.add('selected');
}
});
selectDeselectButtons.onclick = () => {
const buttonText = selectDeselectButtons.value;
if (buttonText === "Select All") {
selectInput.value = options.map(option => option.value);
selectInputValue = options.map(option => option.value);
form.value = selectInputValue;
optionListItems.forEach(t => {
t.classList.add('selected');
});
} else if (buttonText === "Deselect All") {
selectInput.value = [];
selectInputValue = [];
form.value = [];
optionListItems.forEach(t => {
t.classList.remove('selected');
});
}
form.dispatchEvent(new CustomEvent("input", { bubbles: true }));
};
//modified from inputs formula by https://observablehq.com/@jashkenas/inputs
selectInput.onchange = e => {
e && e.preventDefault();
const value = selectInput.value;
if (form.output) {
const out = value;
if (out instanceof window.Element) {
while (form.output.hasChildNodes()) {
form.output.removeChild(form.output.lastChild);
}
form.output.append(out);
} else {
form.output.value = out;
}
}
// Update form.value with the selectInput value
form.value = value;
form.dispatchEvent(new CustomEvent("input", { bubbles: true }));
};
const form = html`<label style='font-weight:bold;font-size:14px;'>${inputLabel}</label><form style='width:250px' id=${inputId}>${dropdownButton} ${innerDropdown}</form>`
form.classList.add("dropdown-form");
form.value = selected;
// clicking anywhere outside the dropdown closes it
const closeOnClickOutside = (event) => {
if (!form.isConnected) {
// form was discarded by a re-render of the cell, so drop the stale listener
document.removeEventListener("click", closeOnClickOutside);
} else if (!form.contains(event.target)) {
innerDropdown.style.display = "none";
}
};
document.addEventListener("click", closeOnClickOutside);
const updatePlaceholderText = () => {
const selectedValues = form.value;
if (selectedValues.length > 0) {
const filteredLabels = options
.filter(option => selectedValues.includes(option.value))
.map(filteredOption => {
// Check if the label is an HTML element
if (typeof filteredOption.label === 'object' && filteredOption.label instanceof HTMLElement) {
return filteredOption.label.outerHTML;
} else {
return filteredOption.label; // Assuming label is a string if not HTML
}
});
const concatenatedText = filteredLabels.join(', ');
dropdownButton.querySelector('.filter-selected').innerHTML = `${concatenatedText}`;
} else {
dropdownButton.querySelector('.filter-selected').innerHTML = placeholderText;
}
};
// ... (existing code)
// Listen for changes in form.value
form.addEventListener('input', updatePlaceholderText);
// Initial update of placeholder text
updatePlaceholderText();
return form;
}
html`
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<style>
.filter-selected {
text-wrap:nowrap;
overflow:hidden;
display:flex;
align-items:center;
}
.dropdown-action-buttons-container {
padding:10px;
}
.dropdown-action-buttons {
width:100%;
}
.dropdown-action-buttons button {
width:49%;
font-size:14px;
height:30px;
background-color:#E9E9E9;
}
.dropdown-list a {
width:100%;
display:flex;
align-items:center;
min-height:14px!important;
}
.dropdown-list .text {
width:90%;
}
.dropdown-inner {
border:1px #ccc solid;
border-radius:4px;
background-color:white;
margin-top:5px;
position:absolute;
z-index:2000;
}
.dropdown-list .checkmark {
width:10%:
}
.dropdown-list {
padding:0px;
margin:0px;
line-height:0px;
list-style: none;
}
.dropdown-list li {
display:flex;
align-content:center;
min-height:35px;
font-size:13px;
}
li .checkmark {
display:none;
float:right;
padding-right:5px;
}
li.selected .checkmark {
display:flex!important;
}
.dropdown-list li:hover {
background-color:whitesmoke!important;
cursor:pointer;
}
.dropdown-list>li>a {
padding: 10px 20px!important;
white-space: nowrap;
}
.dropdown-form button {
background-color:white!important;
color: #333!important;
border:1px #ccc solid!important;
border-radius:4px!important;
cursor:pointer;
}
.dropdown-button button {
width:100%!important;
height:35px!important;
}
</style>`Plot = import("https://esm.sh/@observablehq/plot@0.6.17")
overview = transpose(overview_)
task_info = ({
"hearts-and-flowers": "Hearts & Flowers",
"same-different-selection": "Same & Different",
"memory-game": "Memory",
"egma-math": "Math",
"matrix-reasoning": "Pattern Matching",
"mental-rotation": "Shape Rotation",
"trog": "Sentence Understanding",
"vocab": "Vocabulary",
"theory-of-mind": "Stories",
"pa": "Language Sounds",
"sre": "Sentence Reading",
"swr": "Word Reading",
})
ov = overview.map(obj => ({
...obj,
task_label: task_info[obj.task_id],
date: d3.utcParse("%Y-%m-%d")(obj.date),
score: obj.scored ? obj.run_id_hash : null
}))displayScheme = [
[204, 102, 119], // red
[221, 204, 119], // yellow
//[153, 153, 51], // yellow-green 17,119,51
[ 17, 119, 51], // green
[ 68, 170, 153], // teal
[136, 204, 238], // light blue
[ 51, 34, 136], // dark blue
[170, 68, 153], // magenta
]
//['#CC6677', '#332288', '#DDCC77', '#117733', '#88CCEE','#882255', '#44AA99', '#999933', '#AA4499']
displayConfig = ([
{ index: 0, property: "dataset", label: "Datasets", icon: "archive" },
{ index: 1, property: "team", label: "Teams", icon: "geo" },
{ index: 2, property: "language", label: "Languages", icon: "translate" },
{ index: 3, property: "task_id", label: "Tasks", icon: "ui-checks-grid" },
{ index: 4, property: "user_id_hash", label: "Participants", icon: "person-arms-up" },
{ index: 5, property: "score", label: "Scores", icon: "bullseye" },
{ index: 6, property: "run_id_hash", label: "Assessments", icon: "clipboard-data" },
])
displayBox = ({ index, property, label, icon }) => {
const values = new Set(ov.map(d => d[property]))
const color = displayScheme[index]
const color_str = `${color[0]}, ${color[1]}, ${color[2]}`
const borderColor = `rgb(${color_str})`
const backgroundColor = `rgba(${color_str}, 0.4)`
return html`<div class="callout callout-style-default callout-note no-icon callout-empty-content callout-titled callout-display" style="border-left-color: ${borderColor};">
<div class="callout-header d-flex align-content-center" style="background-color: ${backgroundColor};">
<div class="callout-title-container flex-fill">
<span class="display-box">
<span class="display-icon"><i class="bi-${icon}" role="img" aria-hidden="true"></i></span>
<span class="display-content">
<span class="display-var">${label}</span>
<span class="display-val">${values.size}</span>
</span>
</span>
</div>
</div>
</div>`
}
boxes = displayConfig.map(displayBox)
html`<div class="display-box-container">${boxes}</div>`min_date = (days) => d3.utcDay.offset(d3.utcDay(new Date()), 1 - days)
// overview data filtered by the recency dropdown, shared by both plots
ovf = recency === null ? ov : ov.filter(d => d.date >= min_date(recency))
// distinct count of a variable over some rows, skipping rows where it is
// missing -- score is null for unscored runs, which would otherwise count as a
// value of its own and add one to every group
n_distinct = (rows, v) => new Set(rows.filter(d => d[v] !== null).map(d => d[v])).size
// display label of the currently selected value of a dropdown
opt_label = (opts, value) => [...opts].find(([label, v]) => v === value)[0]
scheme = tol.QualMuted // color scheme
default_color = scheme[1] // default color when there's no grouping variable
plt_color = group_var || default_color // plot color variable
// bootstrap icon for each radio option, reusing the display box icons above.
// labels are unique across the inputs, except dataset/team/language/task which
// mean the same thing in both places
opt_icons = ({
"Assessments": "clipboard-data",
"Scores": "bullseye",
"Participants": "person-arms-up",
"Dataset": "archive",
"Team": "geo",
"Language": "translate",
"Task": "ui-checks-grid",
//"None": "slash-circle",
"Age": "people",
"Date administered": "calendar-event",
//"7 days": "calendar3",
//"30 days": "calendar3",
//"90 days": "calendar3",
//"365 days": "calendar3",
//"Anytime": "infinity"
})
// radio option rendered with its icon, falling back to text alone
icon_label = ([label]) => opt_icons[label]
? html`<span><i class="bi bi-${opt_icons[label]} opt-icon"></i>${label}</span>`
: html`<span>${label}</span>`
// shown in place of a plot when the filters exclude everything
no_data = () => html`<p class="filter-error"><i class="bi bi-exclamation-triangle"></i> No data selected</p>`count_opts = new Map([["Assessments", "run_id_hash"], ["Scores", "score"], ["Participants", "user_id_hash"]])
viewof count_var = Inputs.radio(count_opts, { label: "Count of", value: "run_id_hash", format: icon_label })
group_opts = new Map([["Dataset", "dataset"], ["Team", "team"], ["Language", "language"], ["Task", "task_label"], ["None", null]])
viewof group_var = Inputs.radio(group_opts, { label: "Grouped by", value: "language", format: icon_label })
recency_opts = new Map([["7 days", 7], ["30 days", 30], ["90 days", 90], ["365 days", 365], ["Anytime", null]])
viewof recency = Inputs.radio(recency_opts, { label: "Collected within past", value: null, format: icon_label })
y_var_opts = new Map([["Dataset", "dataset"], ["Team", "team"], ["Language", "language"], ["Task", "task_label"]])
viewof y_var = Inputs.radio(y_var_opts, { label: "For each", value: "task_label", format: icon_label })
marginLeft = 152
ovf.length === 0 ? no_data() : Plot.plot({
style: { fontFamily: "var(--sans-serif)", fontSize: 12 },
width: 0.43 * width,
marginLeft: marginLeft,
marginTop: 10,
marginBottom: 40,
x: { label: `${opt_label(count_opts, count_var)} count`, axis: "bottom", line: true },
y: { label: opt_label(y_var_opts, y_var), labelAnchor: "top", tickSize: 0, tickPadding: 2 },
color: {
range: scheme,
legend: group_var !== null,
marginLeft: marginLeft,
className: "color-legend"
},
marks: [
Plot.barX(ovf, Plot.groupY(
{ x: g => n_distinct(g, count_var) },
{ y: y_var, fill: plt_color, sort: { y: "x", reverse: true, }, inset: 1 }
)),
Plot.textX(ovf, Plot.stackX(Plot.groupY(
{ x: g => n_distinct(g, count_var), text: g => n_distinct(g, count_var) },
{ y: y_var, z: plt_color, fill: "white" }
))),
]
})
hist_bin_opts = new Map([["Age", "age"], ["Date administered", "date"]])
viewof hist_bin = Inputs.radio(hist_bin_opts, { label: "Binned by", value: "age", format: icon_label })
// a participant has one age and date per run, so there is no single value to
// bin them by -- say so rather than plot something misleading
count_var === "user_id_hash"
? html`<p class="filter-error"><i class="bi bi-info-circle"></i> Participants can't be binned by age or date, since each participant has multiple assessments</p>`
: ovf.length === 0 ? no_data() : Plot.plot({
style: { fontFamily: "var(--sans-serif)", fontSize: 12 },
width: 0.43 * width,
height: 300,
marginTop: 20,
marginBottom: 50,
x: { label: opt_label(hist_bin_opts, hist_bin), line: true },
y: { label: "Count" },
color: {
legend: group_var !== null,
range: scheme,
className: "color-legend"
},
marks: [
Plot.rectY(ovf, Plot.binX(
{ y: g => n_distinct(g, count_var) },
{ fill: plt_color, x: { value: hist_bin } }
)),
]
})