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 interval(range = [], options = {}) {
const [min = 0, max = 1] = range;
const {
step = .001,
label = null,
value = [min, max],
format = ([start, end]) => `${start} … ${end}`,
color,
width,
theme,
__ns__ = randomScope(),
} = options;
const css = `
#${__ns__} {
//font: 13px/1.2 var(--sans-serif);
display: flex;
align-items: baseline;
flex-wrap: wrap;
max-width: 100%;
width: auto;
flex-direction: column;
}
@media only screen and (min-width: 30em) {
#${__ns__} {
flex-wrap: nowrap;
width: 360px;
}
}
#${__ns__} .label {
//width: 120px;
//padding: 5px 0 4px 0;
//padding: 5px 0 0 0;
//margin-right: 6.5px;
flex-shrink: 0;
}
#${__ns__} .form {
display: flex;
width: 100%;
}
#${__ns__} .range {
flex-shrink: 1;
width: 100%;
}
#${__ns__} .range-slider {
width: 100%;
margin-bottom: .3em;
margin-top: .3em;
}
`;
const $range = rangeInput({min, max, value: [value[0], value[1]], step, color, width, theme});
const $output = html`<output>`;
const $view = html`<div id=${__ns__}>
${label == null ? '' : html`<div class="label">${label}`}
<div class=form>
<div class=range>
${$range}<div class=range-output>${$output}</div>
</div>
</div>
${html`<style>${css}`}
`;
const update = () => {
const content = format([$range.value[0], $range.value[1]]);
if(typeof content === 'string') $output.value = content;
else {
while($output.lastChild) $output.lastChild.remove();
$output.appendChild(content);
}
};
$range.oninput = update;
// the slider only fires input on release, so follow its drags for the readout
$range.addEventListener('dragging', update);
update();
return Object.defineProperty($view, 'value', {
get: () => $range.value,
set: ([a, b]) => {
$range.value = [a, b];
update();
},
});
}
// date version of interval. the underlying input only handles numbers, so work
// in epoch milliseconds internally and report the value as dates. step is in
// days, and the range is widened to whole UTC days so values land on dates.
function dateInterval(range = [], options = {}) {
const day = 864e5;
const fmtDate = d3.utcFormat("%Y-%m-%d");
const [min, max] = [d3.utcDay.floor(range[0]), d3.utcDay.ceil(range[1])];
const {
step = 1,
value = [min, max],
format = ([start, end]) => `${fmtDate(start)} … ${fmtDate(end)}`,
...rest
} = options;
const $interval = interval([+min, +max], {
...rest,
step: step * day,
// widen outward, else the slider snaps to the nearest day and clips the ends
value: [+d3.utcDay.floor(value[0]), +d3.utcDay.ceil(value[1])],
format: ([start, end]) => format([new Date(start), new Date(end)]),
});
// input events bubble up from the slider, so the wrapper works as a viewof
const $view = html`<div>${$interval}`;
return Object.defineProperty($view, 'value', {
get: () => $interval.value.map(ms => new Date(ms)),
set: ([a, b]) => {
$interval.value = [+a, +b];
},
});
}
function rangeInput(options = {}) {
const {
min = 0,
max = 100,
step = 'any',
value: defaultValue = [min, max],
color,
width,
theme = theme_Flat,
} = options;
const controls = {};
const scope = randomScope();
const clamp = (a, b, v) => v < a ? a : v > b ? b : v;
// Will be used to sanitize values while avoiding floating point issues.
const input = html`<input type=range ${{min, max, step}}>`;
const dom = html`<div class=${`${scope} range-slider`} style=${{
color,
width: cssLength(width),
}}>
${controls.track = html`<div class="range-track">
${controls.zone = html`<div class="range-track-zone">
${controls.range = html`<div class="range-select" tabindex=0>
${controls.min = html`<div class="thumb thumb-min" tabindex=0>`}
${controls.max = html`<div class="thumb thumb-max" tabindex=0>`}
`}
`}
`}
${html`<style>${theme.replace(/:scope\b/g, '.'+scope)}`}
</div>`;
let value = [], changed = false, dragging = false;
Object.defineProperty(dom, 'value', {
get: () => [...value],
set: ([a, b]) => {
value = sanitize(a, b);
updateRange();
},
});
const sanitize = (a, b) => {
a = isNaN(a) ? min : ((input.value = a), input.valueAsNumber);
b = isNaN(b) ? max : ((input.value = b), input.valueAsNumber);
return [Math.min(a, b), Math.max(a, b)];
}
const updateRange = () => {
const ratio = v => (v - min) / (max - min);
dom.style.setProperty('--range-min', `${ratio(value[0]) * 100}%`);
dom.style.setProperty('--range-max', `${ratio(value[1]) * 100}%`);
};
const dispatch = name => {
dom.dispatchEvent(new Event(name, {bubbles: true}));
};
const setValue = (vmin, vmax) => {
const [pmin, pmax] = value;
value = sanitize(vmin, vmax);
updateRange();
// Only dispatch if values have changed.
if(pmin === value[0] && pmax === value[1]) return;
// mid-drag this only moves the readout; 'input' (which downstream cells
// recompute on) fires once on release rather than on every step crossed
dispatch(dragging ? 'dragging' : 'input');
changed = true;
};
setValue(...defaultValue);
// Mousemove handlers.
const handlers = new Map([
[controls.min, (dt, ov) => {
const v = clamp(min, ov[1], ov[0] + dt * (max - min));
setValue(v, ov[1]);
}],
[controls.max, (dt, ov) => {
const v = clamp(ov[0], max, ov[1] + dt * (max - min));
setValue(ov[0], v);
}],
[controls.range, (dt, ov) => {
const d = ov[1] - ov[0];
const v = clamp(min, max - d, ov[0] + dt * (max - min));
setValue(v, v + d);
}],
]);
// Returns client offset object.
const pointer = e => e.touches ? e.touches[0] : e;
// Note: Chrome defaults "passive" for touch events to true.
const on = (e, fn) => e.split(' ').map(e => document.addEventListener(e, fn, {passive: false}));
const off = (e, fn) => e.split(' ').map(e => document.removeEventListener(e, fn, {passive: false}));
let initialX, initialV, target;
function handleDrag(e) {
// Gracefully handle exit and reentry of the viewport.
if(!e.buttons && !e.touches) {
handleDragStop();
return;
}
dragging = true;
const w = controls.zone.getBoundingClientRect().width;
e.preventDefault();
handlers.get(target)((pointer(e).clientX - initialX) / w, initialV);
}
function handleDragStop(e) {
off('mousemove touchmove', handleDrag);
off('mouseup touchend', handleDragStop);
if(changed) {
dispatch('input');
dispatch('change');
}
}
invalidation.then(handleDragStop);
dom.ontouchstart = dom.onmousedown = e => {
dragging = false;
changed = false;
if(!handlers.has(e.target)) return;
on('mousemove touchmove', handleDrag);
on('mouseup touchend', handleDragStop);
e.preventDefault();
e.stopPropagation();
target = e.target;
initialX = pointer(e).clientX;
initialV = value.slice();
};
controls.track.onclick = e => {
if(dragging) return;
changed = false;
const r = controls.zone.getBoundingClientRect();
const t = clamp(0, 1, (pointer(e).clientX - r.left) / r.width);
const v = min + t * (max - min);
const [vmin, vmax] = value, d = vmax - vmin;
if(v < vmin) setValue(v, v + d);
else if(v > vmax) setValue(v - d, v);
if(changed) dispatch('change');
};
return dom;
}
function randomScope(prefix = 'scope-') {
return prefix + (performance.now() + Math.random()).toString(32).replace('.', '-');
}
cssLength = v => v == null ? null : typeof v === 'number' ? `${v}px` : `${v}`
html = htl.html
theme_Flat = `
/* Options */
:scope {
color: #3b99fc;
width: 240px;
}
:scope {
position: relative;
display: inline-block;
--thumb-size: 15px;
--thumb-radius: calc(var(--thumb-size) / 2);
//padding: var(--thumb-radius) 0;
margin: 2px;
vertical-align: middle;
}
:scope .range-track {
box-sizing: border-box;
position: relative;
height: 7px;
background-color: hsl(0, 0%, 80%);
overflow: visible;
border-radius: 4px;
padding: 0 var(--thumb-radius);
}
:scope .range-track-zone {
box-sizing: border-box;
position: relative;
}
:scope .range-select {
box-sizing: border-box;
position: relative;
left: var(--range-min);
width: calc(var(--range-max) - var(--range-min));
cursor: ew-resize;
background: currentColor;
height: 7px;
border: inherit;
}
/* Expands the hotspot area. */
:scope .range-select:before {
content: "";
position: absolute;
width: 100%;
height: var(--thumb-size);
left: 0;
top: calc(2px - var(--thumb-radius));
}
:scope .range-select:focus,
:scope .thumb:focus {
outline: none;
}
:scope .thumb {
box-sizing: border-box;
position: absolute;
width: var(--thumb-size);
height: var(--thumb-size);
background: #fcfcfc;
top: -4px;
border-radius: 100%;
border: 1px solid hsl(0,0%,55%);
cursor: default;
margin: 0;
}
:scope .thumb:active {
box-shadow: inset 0 var(--thumb-size) #0002;
}
:scope .thumb-min {
left: calc(-1px - var(--thumb-radius));
}
:scope .thumb-max {
right: calc(-1px - var(--thumb-radius));
}
`// categorical filter input: a checkbox list that keeps every value visible,
// plus group actions and a per-value "only" for isolating a single value.
// wraps Inputs.checkbox, so .value stays an array in data order and the
// element still works as a viewof
filter_checkbox = (values, options = {}) => {
// actions: false keeps the counter but drops only/all/none, for variables
// with so few values that isolating one is not worth the extra controls
const { actions = true, ...rest } = options
const input = Inputs.checkbox(values, { value: values, ...rest })
const rows = input.querySelector("div") // the div holding one label per value
// set the selection and notify downstream cells (the setter alone is silent)
const set = (vals) => {
input.value = vals
input.dispatchEvent(new Event("input", { bubbles: true }))
}
const action = (text, fun, cls = "cat-action") => {
const el = html`<button type="button" class=${cls}>${text}</button>`
el.onclick = (event) => {
event.preventDefault()
event.stopPropagation()
fun()
}
return el
}
if (actions) {
// group actions
rows.before(html`<div class="cat-header">
${action("all", () => set(values))}
${action("none", () => set([]))}
</div>`)
// per-value "only": lives inside the pill, and is always in the layout (just
// faded) so that revealing it on hover does not reflow the wrapped pills
const labels = [...rows.children]
labels.forEach((label, i) => {
const btn = action("only", () => set([values[i]]), "cat-only")
btn.title = `only ${values[i]}`
label.append(btn)
})
}
// selection summary, shown in the callout header so that it is readable while
// the callout is collapsed. the input is not in the document yet when this
// cell runs, so wait for it to be inserted before looking for the header
let header_count = null
requestAnimationFrame(() => {
const header = input.closest(".callout")?.querySelector(".callout-title-container")
if (!header) return
// the cell re-runs whenever the values change (e.g. switching data source),
// and the header lives outside the cell output, so a summary left behind by
// a previous instance has to be cleared rather than appended to
header.querySelectorAll(".cat-summary").forEach(el => el.remove())
header_count = html`<span class="cat-summary"></span>`
header.append(header_count)
update()
})
const update = () => {
if (header_count) header_count.textContent = `${input.value.length} of ${values.length}`
}
input.addEventListener("input", update)
update()
return input
}html`<style>
.cat-header {
display: flex;
align-items: baseline;
gap: 0.3em;
margin-bottom: 0.35em;
/* the inputs form is a wrapping flex container, so claim the whole line to
keep short checkbox sets from sitting next to the buttons */
flex: 0 0 100%;
}
/* button-like, but outlined rather than filled so they read as actions
rather than as another value pill.
quarto tags buttons in cell output with .btn-quarto, and layout.scss styles
those via div.cell-output-display, so these selectors have to be specific
enough to win against it (and against .oi-* button { font: inherit }) */
.cat-header button.cat-action {
background: none;
border: 1px solid #ccc;
border-radius: 0.5rem;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
color: #333;
cursor: pointer;
}
.cat-header button.cat-action:hover {
background-color: #fbe4b4;
border-color: #999;
}
.cat-summary {
margin-left: 0.5em;
font-weight: normal;
font-style: italic;
opacity: 0.7;
}
/* the pill is the <label>; keep the only-button in flow but faded.
quarto gives these buttons bootstrap's .btn classes, so .btn:hover paints a
background behind them -- turning that off needs a selector that
out-specifies it, and the hover cue is the text colour instead */
label button.cat-only {
border: none;
background: none;
padding: 0 0 0 0.35em;
margin: 0;
font-size: 0.75em;
line-height: 1;
color: #333;
cursor: pointer;
opacity: 0.3;
}
/* revealed by hovering anywhere on the pill */
label:hover button.cat-only {
opacity: 1;
}
/* highlighted by hovering the word itself. !important because bootstrap's
.btn:hover paints a background and colour here, and out-specifying it is
brittle -- quarto adds the .btn classes to these buttons behind our back */
label button.cat-only:hover,
label button.cat-only:focus {
background: none !important;
box-shadow: none !important;
color: #2c728e !important;
opacity: 1;
}
</style>`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>`task_info = ({
"hf": "Hearts & Flowers",
"sds": "Same & Different",
"mg": "Memory",
"math": "Math",
"matrix": "Pattern Matching",
"mrot": "Shape Rotation",
"trog": "Sentence Understanding",
"vocab": "Vocabulary",
"tom": "Stories",
"pa": "Language Sounds",
"sre": "Sentence Reading",
"swr": "Word Reading",
})
// time_started is always utc, but arrives in two formats: "2024-05-07 09:06:25"
// via ojs_define from the r loader, and iso ("2024-05-07T13:05:41.810Z") from the
// redivis js client used by the data source switcher
parse_time = (t) => {
if (t == null) return null
if (t instanceof Date) return t
return d3.utcParse("%Y-%m-%d %H:%M:%S")(t) || d3.isoParse(t)
}
prep_scores = (sc) => {
return sc.map(obj => {
return tag_vars.reduce((acc, key) => ({
...acc,
[key]: acc[key] === undefined || acc[key] === null ? "NA" : acc[key]
}), {
...obj,
adaptive: obj.adaptive ? "adaptive" : "non-adaptive",
task_label: task_info[obj.task_code],
team: obj.team ? obj.team.replaceAll("_", "-") : obj.site.replaceAll("_", "-"),
dataset: obj.dataset.replaceAll("_", "-"),
date: parse_time(obj.time_started),
scoring_method: obj.scoring_model === undefined ? obj.score_type : `IRT - ${obj.scoring_model}`
})
})
.filter(d => d.task_label !== "NA")
.sort((a, b) => {
const keysOrder = Object.keys(task_info);
return keysOrder.indexOf(a.task_code) - keysOrder.indexOf(b.task_code);
})
}
mutable scores = prep_scores(initial_scores)vi = ({
"age": { label: "Age (years)", filter: true, tip: true, range: [2, 16], value: [4.5, 12] },
"age_gap": { label: "Age gap (months)", filter: true, tip: false, range: [0, 24], value: [0, 24] },
"date": { label: "Date", filter: true, tip: false, range: dates, value: dates },
"score": { label: "Score", filter: false, tip: true, range: null },
"score_se": { label: "Score SE", filter: false, tip: true, range: null },
"num_attempted": { label: "num_attempted", filter: false, tip: true, range: null }
})
// info for categorical variables
// color = include in color dropdown?, tip = include in tooltip?
// note: filter property not actually used programmatically
ti = ({
"dataset": { label: "Dataset", filter: true, color: true, tip: true },
"team": { label: "Team", filter: true, color: true, tip: true },
"language": { label: "Language", filter: true, color: true, tip: false },
"task_label": { label: "Task", filter: true, color: false, tip: false },
"adaptive": { label: "Adaptive", filter: true, color: true, tip: false },
"scoring_method": { label: "Scoring method", filter: true, color: true, tip: false },
"run_id": { label: "run_id", filter: false, color: false, tip: true },
})dates = [d3.min(scores.map(d => d.date)), d3.max(scores.map(d => d.date))]
// formatting function for how two ended slider displays selected range
// e.g. [5, 12]
interval_number = ([start, end]) => `[${Plot.formatNumber("en-US")(start)}; ${Plot.formatNumber("en-US")(end)}]`
interval_date = ([start, end]) => `[${d3.utcFormat("%Y-%m-%d")(start)}; ${d3.utcFormat("%Y-%m-%d")(end)}]`
// integer-rounded min and max of an array
// range = (x) => [Math.floor(d3.min(x)), Math.ceil(d3.max(x))]
// interval input for a given numeric variable (interval_fun = dateInterval for dates)
var_interval = (v, interval_format, interval_fun = interval) => {
return interval_fun(vi[v].range, {
step: 1, label: vi[v].label, value: vi[v].value,
format: interval_format, width: "95%", color: "#f7ca69"
})
}cat_checkbox = (tags, t) => filter_checkbox(tags[t])
viewof age_range = var_interval("age", interval_number)
viewof age_gap_range = var_interval("age_gap", interval_number)
viewof date_range = var_interval("date", interval_date, dateInterval)
viewof dataset_vals = cat_checkbox(tags, "dataset")
viewof team_vals = cat_checkbox(tags, "team")
viewof language_vals = cat_checkbox(tags, "language")
viewof task_label_vals = cat_checkbox(tags, "task_label")
// just two values, so only/all/none would be noise -- but keep the counter
viewof adaptive_vals = filter_checkbox(tags["adaptive"], { actions: false })
viewof scoring_method_vals = cat_checkbox(tags, "scoring_method")
// populates datasource selector -- first click should open redivis auth pop up
viewof auth_button = Inputs.button("Populate data sources", {
reduce: async () => {
const ds = await getDatasets()
mutable dn = allowedDatasetNames(ds)
}
})
mutable dn = [] // available datasource names
viewof datasource = Inputs.select(dn)
// replaces current scores with scores from selected datasource
viewof switch_datasource_button = Inputs.button("Switch data source", {
reduce: async () => {
const ds_scores = await getScores(datasource);
mutable scores = prep_scores(ds_scores);
}
})
range_filter = (v, v_range) => {
return (d) => d[v] === undefined || (d[v] >= v_range[0] & d[v] <= v_range[1])
}
// generate overlap filter function for a given categorical variable and input values
overlap_filter = (t, t_vals) => {
const vals = new Set(t_vals) // set lookup, not a scan per row
return (d) => vals.has(d[t])
}
// generate overlap filter function as above but that defaults to including everything if input values empty
overlap_null_filter = (t, t_vals) => {
return (d) => t_vals.length ? t_vals.some(v => d[t].includes(v)) : true
}
// specify range-based inputs (as variable, values)
range_inputs = ([
{ v: "age", vals: age_range },
{ v: "date", vals: date_range },
])
// specify overlap-based inputs (as variable, values)
overlap_inputs = ([
{ v: "dataset", vals: dataset_vals },
{ v: "team", vals: team_vals },
{ v: "task_label", vals: task_label_vals },
{ v: "language", vals: language_vals },
{ v: "adaptive", vals: adaptive_vals },
//{ v: "score_type", vals: score_type_vals },
//{ v: "scoring_model", vals: scoring_model_vals },
{ v: "scoring_method", vals: scoring_method_vals },
])
//apply_filters = (df, ri, oi) => {
// const overlap_filters = oi.map(({ v, vals }) => overlap_filter(v, vals))
// const range_filters = ri.map(({ v, vals }) => range_filter(v, vals))
// const all_filters = [...range_filters, ...overlap_filters]
// return all_filters.reduce((d, fun) => d.filter(fun), df)
//}
//sf = apply_filters(scores, range_inputs, overlap_inputs)
// generate filter function for each input and combine them
range_filters = range_inputs.map(({ v, vals }) => range_filter(v, vals))
overlap_filters = overlap_inputs.map(({ v, vals }) => overlap_filter(v, vals))
all_filters = [...range_filters, ...overlap_filters]
// apply filters to score data (one pass, rather than one pass per filter)
sf = scores.filter(d => all_filters.every(fun => fun(d)))
// apply age gap filter
ag = ls.filter(d => d.age_gap === NaN || (d.age_gap >= age_gap_range[0] & d.age_gap <= age_gap_range[1]))
// set, not an array: this is looked up once per row of sf
ag_runs = new Set(ag.map(d => d.runs).flat())
scoresf = sf.filter(d => ag_runs.has(d.run_id))
// plot type selector
viewof plot_type = Inputs.radio(
new Map([["Cross-sectional", "dot"], ["Longitudinal", "line"]]),
{value: "dot", label: "Data view", format: icon_label}
)
cat_array = Object.keys(ti).filter((t) => ti[t].color).map((t) => [ti[t].label, t])
color_keys = cat_array.map(([label, k]) => k)
cat_vars = new Map([...cat_array, ["None", null]])
// bootstrap icon for each option, matching the filter callout headers
opt_icons = ({
//"None": "slash-circle",
"Dataset": "archive",
"Team": "geo",
"Language": "translate",
"Adaptive": "speedometer",
"Scoring method": "sliders",
"Cross-sectional": "calendar3",
"Longitudinal": "graph-up",
"Linear": "slash-lg",
"Loess": "bezier2",
"Overall": "dash-lg",
"By color": "palette"
})
// 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>`
viewof color_var = Inputs.radio(cat_vars, { value: "dataset", label: "Color", format: icon_label })
// regression line type selector
viewof trend_type = Inputs.radio(
new Map([["Linear", "linear"], ["Loess", "loess"]]),
{value: "linear", label: "Trend type", format: icon_label}
)
// regression line grouping selector
viewof trend_group = Inputs.radio(
new Map([["Overall", false], ["By color", true]]),
{value: false, label: "Trend group", format: icon_label}
)
x_var = "age" // x
y_var = "score" // y
r_var = "score_se" // dot radius
z_var = "user_id" // line group
f_var = "task_label" // facets
//
// plot colors
// set up to keep color mapping consistent regardless of filters but include
// only present values in legend
//
scheme = tol.QualMuted // color scheme
default_color = scheme[0] // default color when there's no color variable
unk_color = "lightgrey" // color for missing values
plt_color = color_var || default_color // plot color variable
// color variable values -- null or all possible values for variable except NA
color_vals = color_var === null ? [default_color] : tags[color_var].filter((v) => v !== "NA")
// domain: values + NA
color_domain = [...color_vals, "NA"]
// range: corresponding values from scheme (cycling if there are more values
// than colors) + missing color
color_range = [...color_vals.map((v, i) => scheme[i % scheme.length]), unk_color]
// map from domain to range
color_map = color_domain.map((v, i) => [v, color_range[i]])
// drop elements from map if values not in data
// values actually present in the data, gathered once rather than rescanned
// for every entry in the map
present_colors = new Set(scoresf.map((d) => d[color_var]))
colors = Object.fromEntries(color_map.filter(([k, v]) => present_colors.has(k)))
// create facet specification to emulate facet wrap
// given data, column name to facet by, and number of columns
facet_wrap = (df, col, ncol) => {
const keys = Array.from(d3.union(df.map((d) => d[col])));
const index = new Map(keys.map((key, i) => [key, i]));
const fx = (key) => index.get(key) % ncol;
const fy = (key) => Math.floor(index.get(key) / ncol);
return ({
fx: (d) => fx(d[col]),
fy: (d) => fy(d[col]),
text: Plot.text(keys, {fx, fy, dy: -12, frameAnchor: "top", fontWeight: "bold"})
//text: Plot.text(keys, {fx, fy, frameAnchor: "top", fontWeight: "bold"})
})
}
// minimum number of data points for a group to get a regression line
trend_min_n = 30
// data grouped by facet and (if by isn't null) by value of by, dropping groups
// with fewer than trend_min_n points
trend_groups = (df, by) => Array.from(
d3.group(df, (d) => d[f_var], (d) => by === null ? null : d[by]),
([f_val, g_map]) => Array.from(g_map, ([g_val, points]) => ({ f_val, g_val, points }))
).flat().filter(({ points }) => points.length >= trend_min_n)
// loess curves for data, one per facet and (if by isn't null) per value of by
loess_curves = (df, by) => trend_groups(df, by).map(
({ f_val, g_val, points }) => regression.regressionLoess()
.x((d) => d[x_var])
.y((d) => d[y_var])
.bandwidth(0.75)(points) // same as default span of R's loess
.map(([x, y]) => ({ [f_var]: f_val, [by]: g_val, [x_var]: x, [y_var]: y }))
).flat()
// marks for regression lines, linear or loess, overall or per color group
trend_marks = (df, facet) => {
const by = trend_group ? color_var : null
const opts = {
x: x_var,
y: y_var,
stroke: trend_group ? plt_color : "black",
strokeWidth: 2,
fx: facet.fx,
fy: facet.fy
}
return trend_type === "linear"
? Plot.linearRegressionY(trend_groups(df, by).flatMap((g) => g.points), opts)
: Plot.line(loess_curves(df, by), opts)
}
all_vars = [...Object.entries(ti), ...Object.entries(vi)] // all variables
tip_vars = all_vars.filter(([k, v]) => v.tip) // variables with "tip" property
dot_channels = Object.fromEntries(tip_vars.map(([k, v]) => [k, k]))
// marks for dots
dot_marks = Plot.marks(
// facet labels
dot_facet.text,
// points
Plot.dot(scoresf, {
x: x_var,
y: y_var,
r: r_var,
stroke: plt_color,
strokeOpacity: 0.2,
fx: dot_facet.fx,
fy: dot_facet.fy
}),
// regression lines
trend_marks(scoresf, dot_facet),
// tooltips
Plot.tip(scoresf, Plot.pointer({
x: x_var,
y: y_var,
stroke: plt_color,
fx: dot_facet.fx,
fy: dot_facet.fy,
channels: dot_channels,
format: { x: null, y: null, fx: null, fy: null }
}))
)
ls = Array.from(
d3.group(sf, d => d[f_var], d => d[z_var]),
([f_val, z_map]) => Array.from(
z_map,
([z_val, points]) => ({
[f_var]: f_val,
[z_var]: z_val,
// every colourable variable, not just the selected one: referencing
// color_var here would make a colour change invalidate this summary, and
// with it ag, ag_runs, scoresf and every downstream cell
...Object.fromEntries(color_keys.map((k) => [k, points[0][k]])),
[x_var]: d3.mean(points, d => d[x_var]),
[y_var]: d3.mean(points, d => d[y_var]),
n_scores: points.length,
age_gap: 12*(d3.max(points, d => d[x_var]) - d3.min(points, d => d[x_var])),
runs: points.map(d => d.run_id)
// [`${x_var}_mean`]: d3.mean(points, d => d[x_var]),
// [`${y_var}_mean`]: d3.mean(points, d => d[y_var]),
// [`${x_var}_range`]: d3.extent(points, d => d[x_var]),
// [`${y_var}_range`]: d3.extent(points, d => d[y_var]),
})
)
).flat()
// filter line segment summary to users with more than one score
line_summary = ls.filter(d => d.n_scores > 1)
// filter scores to runs filtered line summary
// set, not an array: this is looked up once per row of scoresf
long_runs = new Set(line_summary.map(d => d.runs).flat())
scoresl = scoresf.filter(d => long_runs.has(d.run_id))
// channels for line tooltips (everything in summary other than facet variable)
//line_vars = Object.keys(line_summary[0]).filter(d => d !== f_var)
// color_var is null when nothing is selected, and a null channel makes Plot
// throw "missing channel value" as soon as the mark is constructed
line_vars = [color_var, z_var, "n_scores", "age_gap"].filter((v) => v !== null)
line_channels = Object.fromEntries(line_vars.map(k => [k, k]))
// marks for lines
line_marks = Plot.marks(
// facet labels
line_facet.text,
// spaghetti lines
Plot.line(scoresl, {
x: x_var,
y: y_var,
z: z_var,
stroke: plt_color,
strokeWidth: 1,
strokeOpacity: 0.5,
fx: line_facet.fx,
fy: line_facet.fy
}),
// regression lines
trend_marks(scoresl, line_facet),
// tooltips
Plot.tip(line_summary, Plot.pointer({
x: x_var,
y: y_var,
stroke: plt_color,
fx: line_summary_facet.fx,
fy: line_summary_facet.fy,
channels: line_channels,
format: { x: null, y: null, fx: null, fy: null }
}))
)
dot_facet = facet_wrap(scoresf, f_var, 6) // facetting for dots
line_facet = facet_wrap(scoresl, f_var, 6) // facetting for lines
line_summary_facet = facet_wrap(line_summary, f_var, 6) // facetting for line summary
// map plot_type values to marks
marks = ({ dot: dot_marks, line: line_marks })
// values for x axis ticks -- age range with step size 1
//ages = d3.range(age_range[0], age_range[1] + 1, 1)
span = (x) => [Math.floor(d3.min(x)), Math.ceil(d3.max(x))]
age_span = span(scoresf.map(d => d.age))
ages = d3.range(age_span[0], age_span[1] + 1, 1)
// plot scores, or say so when the filters select nothing
scorePlot = scoresf.length === 0
? html`<p class="filter-error"><i class="bi bi-exclamation-triangle"></i> No data selected</p>`
: Plot.plot({
style: { fontFamily: "var(--sans-serif)", fontSize: 12 },
marginBottom: 35,
insetLeft: 5,
width: 1000,
grid: true,
x: { label: "Age (years)", line: true, domain: age_span, ticks: ages, tickFormat: d3.format(".0f") },
y: { label: "Score", line: true },
r: { transform: (s) => s ? 1/s : 1, range: [0, 2] }, // radius inversely proportional
color: {
domain: Object.keys(colors),
range: Object.values(colors),
legend: color_var !== null,
className: "color-legend"
},
facet: { axis: null }, // hide facet labels
marks: marks[plot_type] // use marks according to plot type (dot/line)
})Filter by…
NoteDataset
NoteTeam
NoteLanguage
NoteTask
NoteAdaptive
NoteScoring method
Change data source…
To change data sources, you need a Redivis account. Click on the button below to log in and list data sources you have access to.
redivis = require("redivis")
// get scores table for a given dataset
async function getScores(dataset) {
console.log('getting scores')
const rows = await redivis
.organization('LEVANTE')
.dataset(dataset)
.table('scores')
.listRows()
console.log('got scores')
return rows;
}
// get references to all LEVANTE datasets (with "properties" populated)
async function getDatasets() {
const ds = await redivis
.organization('LEVANTE')
.listDatasets()
return await Promise.all(ds.map(d => d.get()))
}
// filter datasets by authed user's access + get names of "processed" datasets
allowedDatasetNames = (ds) => {
const accessLevels = ["data", "edit"]
//const extraDatasets = ["levante-data-latest", "levante-data-pilots-hackathon"]
const defaultDataset = "levante-data-pilots"
const datasets = ds.filter(d => accessLevels.includes(d.properties.accessLevel))
.filter(d => d.properties.tableCount > 0)
.map(d => d.name)
.filter(n => !n.match("-raw|-metadata-|-example|test-") && n !== defaultDataset)
//.filter(n => n.match("processed") || extraDatasets.includes(n))
.sort()
return [defaultDataset, ...datasets]
}