@vue-skuilder/common-ui 0.2.33 → 0.2.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{MarkdownRenderer-kStoDRNE.mjs → MarkdownRenderer-U_iPiA2x.mjs} +219 -216
- package/dist/MarkdownRenderer-U_iPiA2x.mjs.map +1 -0
- package/dist/admin/admin.css +1 -1
- package/dist/admin/common-ui-admin.es.js +24754 -638
- package/dist/admin/common-ui-admin.es.js.map +1 -1
- package/dist/admin/components/EChart.vue.d.ts +14 -0
- package/dist/admin/components/EChart.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerDiagnostics.vue.d.ts +3 -3
- package/dist/admin/components/LearnerDiagnostics.vue.d.ts.map +1 -1
- package/dist/admin/components/LearnerTagElo.vue.d.ts +14 -0
- package/dist/admin/components/LearnerTagElo.vue.d.ts.map +1 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/common-ui.es.js +3 -3
- package/dist/common-ui.es.js.map +1 -1
- package/dist/common-ui.umd.js +2 -2
- package/dist/common-ui.umd.js.map +1 -1
- package/dist/components/CourseCardBrowser.vue.d.ts +3 -3
- package/dist/components/CourseInformation.vue.d.ts +3 -3
- package/dist/components/PaginatingToolbar.vue.d.ts +3 -3
- package/dist/components/SkMouseTrapToolTip.vue.d.ts +1 -1
- package/dist/composables/CompositionViewable.d.ts +2 -2
- package/dist/composables/CompositionViewable.d.ts.map +1 -1
- package/dist/composables/Displayable.d.ts +6 -1
- package/dist/composables/Displayable.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/admin/components/EChart.vue +59 -0
- package/src/admin/components/LearnerDiagnostics.vue +8 -4
- package/src/admin/components/LearnerTagElo.vue +563 -0
- package/src/admin/index.ts +1 -0
- package/src/composables/CompositionViewable.ts +11 -5
- package/src/composables/Displayable.ts +7 -1
- package/dist/MarkdownRenderer-kStoDRNE.mjs.map +0 -1
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<v-alert v-if="!view" type="info" variant="tonal">
|
|
4
|
+
This learner's registration carries no ELO for this course.
|
|
5
|
+
</v-alert>
|
|
6
|
+
|
|
7
|
+
<template v-else>
|
|
8
|
+
<p class="text-caption text-medium-emphasis mb-4">
|
|
9
|
+
The learner's ELO on each tag (from their course registration) beside their global ELO. A tag's rating starts at
|
|
10
|
+
the learner's global ELO when first graded and moves only on responses that grade it: read it with its response
|
|
11
|
+
count. Bookkeeping tags (<code>intro</code>, <code>expose</code>) are counted, never rated, and are hidden by
|
|
12
|
+
default.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<v-row dense class="mb-2">
|
|
16
|
+
<v-col v-for="tile in tiles" :key="tile.label" cols="6" sm="3">
|
|
17
|
+
<v-card variant="tonal" color="primary" class="pa-2 text-center">
|
|
18
|
+
<div class="text-h6">{{ tile.value }}</div>
|
|
19
|
+
<div class="text-caption">{{ tile.label }}</div>
|
|
20
|
+
</v-card>
|
|
21
|
+
</v-col>
|
|
22
|
+
</v-row>
|
|
23
|
+
|
|
24
|
+
<div class="d-flex align-center flex-wrap mb-1 ga-3">
|
|
25
|
+
<v-chip-group v-model="selectedBatches" multiple column selected-class="text-primary">
|
|
26
|
+
<v-chip
|
|
27
|
+
v-for="b in batchOptions"
|
|
28
|
+
:key="b.key"
|
|
29
|
+
:value="b.key"
|
|
30
|
+
size="small"
|
|
31
|
+
filter
|
|
32
|
+
:variant="b.course ? 'outlined' : 'tonal'"
|
|
33
|
+
:title="b.course ? 'course batch' : 'namespace × role'"
|
|
34
|
+
>
|
|
35
|
+
{{ b.batch.label }} ({{ b.matches }})
|
|
36
|
+
</v-chip>
|
|
37
|
+
</v-chip-group>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="d-flex align-center flex-wrap mb-4 ga-3">
|
|
40
|
+
<v-btn-toggle v-model="sortBy" mandatory density="compact" color="primary" variant="outlined">
|
|
41
|
+
<v-btn value="score" size="small">ELO</v-btn>
|
|
42
|
+
<v-btn value="count" size="small">Responses</v-btn>
|
|
43
|
+
<v-btn value="lastSeen" size="small">Last seen</v-btn>
|
|
44
|
+
<v-btn value="tag" size="small">Name</v-btn>
|
|
45
|
+
</v-btn-toggle>
|
|
46
|
+
<v-text-field
|
|
47
|
+
v-model.number="minCount"
|
|
48
|
+
type="number"
|
|
49
|
+
min="0"
|
|
50
|
+
density="compact"
|
|
51
|
+
variant="outlined"
|
|
52
|
+
hide-details
|
|
53
|
+
label="min responses"
|
|
54
|
+
style="max-width: 140px"
|
|
55
|
+
/>
|
|
56
|
+
<v-text-field
|
|
57
|
+
v-model="search"
|
|
58
|
+
density="compact"
|
|
59
|
+
variant="outlined"
|
|
60
|
+
hide-details
|
|
61
|
+
clearable
|
|
62
|
+
placeholder="filter by tag"
|
|
63
|
+
prepend-inner-icon="mdi-magnify"
|
|
64
|
+
style="max-width: 280px"
|
|
65
|
+
/>
|
|
66
|
+
<v-spacer />
|
|
67
|
+
<span class="text-caption text-medium-emphasis">
|
|
68
|
+
{{ picked.size }}/{{ MAX_PICKED }} plotted
|
|
69
|
+
<v-btn v-if="picked.size" variant="text" size="x-small" @click="picked = new Map()">clear</v-btn>
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<v-alert v-if="visible.length === 0" type="info" variant="tonal">No tags match these filters.</v-alert>
|
|
74
|
+
|
|
75
|
+
<v-row v-else>
|
|
76
|
+
<v-col cols="12" md="5">
|
|
77
|
+
<div class="text-subtitle-2">ELO by tag</div>
|
|
78
|
+
<div class="text-caption text-medium-emphasis mb-1">
|
|
79
|
+
Dot size: responses (log scale). Click a dot or a name to plot its trajectory.
|
|
80
|
+
</div>
|
|
81
|
+
<e-chart v-if="plotted.length" :option="dotOption" :height="dotHeight" @click="onDotClick" />
|
|
82
|
+
<div v-else class="text-caption text-disabled pa-4">No rated tags in this selection.</div>
|
|
83
|
+
</v-col>
|
|
84
|
+
<v-col cols="12" md="7">
|
|
85
|
+
<div class="sk-tag-elo-sticky">
|
|
86
|
+
<div class="text-subtitle-2">Trajectories</div>
|
|
87
|
+
<div class="text-caption text-medium-emphasis mb-1">
|
|
88
|
+
<template v-if="view.seriesFrom">
|
|
89
|
+
From {{ fmtTime(view.seriesFrom) }}, when sessions began recording ELO events. Faint lines are the other
|
|
90
|
+
tags in view; click one to plot it. A jump between recorded events is a session that didn't save its
|
|
91
|
+
events.
|
|
92
|
+
</template>
|
|
93
|
+
<template v-else>No session in this dataset recorded ELO events.</template>
|
|
94
|
+
</div>
|
|
95
|
+
<e-chart v-if="view.seriesFrom" :option="lineOption" :height="380" @click="onLineClick" />
|
|
96
|
+
</div>
|
|
97
|
+
</v-col>
|
|
98
|
+
</v-row>
|
|
99
|
+
|
|
100
|
+
<v-table v-if="visible.length" density="compact" class="mt-4 sk-tag-elo-table">
|
|
101
|
+
<thead>
|
|
102
|
+
<tr>
|
|
103
|
+
<th>Tag</th>
|
|
104
|
+
<th>Batch</th>
|
|
105
|
+
<th class="text-right">ELO</th>
|
|
106
|
+
<th class="text-right">vs global</th>
|
|
107
|
+
<th class="text-right">Responses</th>
|
|
108
|
+
<th>Recent first attempts</th>
|
|
109
|
+
<th>First seen</th>
|
|
110
|
+
<th>Last seen</th>
|
|
111
|
+
<th class="text-right">ELO events</th>
|
|
112
|
+
</tr>
|
|
113
|
+
</thead>
|
|
114
|
+
<tbody>
|
|
115
|
+
<tr
|
|
116
|
+
v-for="r in visible"
|
|
117
|
+
:key="r.tag"
|
|
118
|
+
:class="{ 'sk-pickable': r.score !== null }"
|
|
119
|
+
@click="r.score !== null && toggle(r.tag)"
|
|
120
|
+
>
|
|
121
|
+
<td>
|
|
122
|
+
<span
|
|
123
|
+
class="sk-swatch"
|
|
124
|
+
:style="{ background: picked.has(r.tag) ? slotColor(picked.get(r.tag)!) : 'transparent' }"
|
|
125
|
+
/>
|
|
126
|
+
<code class="text-caption">{{ r.tag }}</code>
|
|
127
|
+
</td>
|
|
128
|
+
<td class="text-caption text-medium-emphasis">{{ r.group }}</td>
|
|
129
|
+
<td class="text-right">
|
|
130
|
+
<template v-if="r.score !== null">{{ r.score }}</template>
|
|
131
|
+
<span v-else class="text-disabled" :title="staleTitle(r)">
|
|
132
|
+
—<template v-if="r.staleScore !== undefined"> ({{ r.staleScore }})</template>
|
|
133
|
+
</span>
|
|
134
|
+
</td>
|
|
135
|
+
<td class="text-right" :class="deltaClass(r.vsGlobal)">{{ signed(r.vsGlobal) }}</td>
|
|
136
|
+
<td class="text-right">{{ r.count }}</td>
|
|
137
|
+
<td>
|
|
138
|
+
<span v-if="r.recent.length" class="sk-ticks">
|
|
139
|
+
<span
|
|
140
|
+
v-for="(p, i) in r.recent"
|
|
141
|
+
:key="i"
|
|
142
|
+
class="sk-tick"
|
|
143
|
+
:class="p.ok ? 'bg-success' : 'bg-error'"
|
|
144
|
+
:title="`${fmtTime(p.at)} · ${p.ok ? 'correct' : 'missed'} · ${p.card}`"
|
|
145
|
+
/>
|
|
146
|
+
<span class="text-caption ml-1">{{ r.recentOk }}/{{ r.recent.length }}</span>
|
|
147
|
+
</span>
|
|
148
|
+
<span v-else class="text-caption text-disabled">—</span>
|
|
149
|
+
</td>
|
|
150
|
+
<td class="text-caption">{{ fmtDate(r.firstSeen) }}</td>
|
|
151
|
+
<td class="text-caption">{{ fmtDate(r.lastSeen) }}</td>
|
|
152
|
+
<td class="text-right">{{ r.events || '' }}</td>
|
|
153
|
+
</tr>
|
|
154
|
+
</tbody>
|
|
155
|
+
</v-table>
|
|
156
|
+
</template>
|
|
157
|
+
</div>
|
|
158
|
+
</template>
|
|
159
|
+
|
|
160
|
+
<script setup lang="ts">
|
|
161
|
+
import { computed, ref, watch } from 'vue';
|
|
162
|
+
import { useTheme } from 'vuetify';
|
|
163
|
+
import type { ECElementEvent, EChartsCoreOption } from 'echarts/core';
|
|
164
|
+
import {
|
|
165
|
+
matchesTagBatch,
|
|
166
|
+
shortTag,
|
|
167
|
+
tagEloView,
|
|
168
|
+
type DiagnosticsInterpreters,
|
|
169
|
+
type EloPoint,
|
|
170
|
+
type LearnerDataset,
|
|
171
|
+
type TagBatch,
|
|
172
|
+
type TagEloRow,
|
|
173
|
+
} from '@vue-skuilder/db/diagnostics';
|
|
174
|
+
import { deltaClass, fmtTime } from '../display';
|
|
175
|
+
import EChart from './EChart.vue';
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A learner's per-tag ELO: which skills they're rated high or low on, how
|
|
179
|
+
* much evidence each rating rests on, and how ratings moved. Filtered by tag
|
|
180
|
+
* batch: the namespace × role groups found on the tags, plus any the course
|
|
181
|
+
* names (`interpreters.tagBatches`).
|
|
182
|
+
*/
|
|
183
|
+
const props = defineProps<{
|
|
184
|
+
dataset: LearnerDataset;
|
|
185
|
+
interpreters?: DiagnosticsInterpreters;
|
|
186
|
+
}>();
|
|
187
|
+
|
|
188
|
+
const view = computed(() => tagEloView(props.dataset));
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Filters
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
const batchOptions = computed(() => {
|
|
195
|
+
const rows = view.value?.rows ?? [];
|
|
196
|
+
const options = [
|
|
197
|
+
...(view.value?.groups ?? []).map((batch) => ({ key: `group:${batch.id}`, batch, course: false })),
|
|
198
|
+
...(props.interpreters?.tagBatches ?? []).map((batch) => ({ key: `course:${batch.id}`, batch, course: true })),
|
|
199
|
+
];
|
|
200
|
+
return options.map((o) => ({ ...o, matches: rows.filter((r) => matchesTagBatch(r.tag, o.batch, r.role)).length }));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const isBookkeeping = (b: TagBatch): boolean => b.roles?.some((r) => r === 'intro' || r === 'expose') ?? false;
|
|
204
|
+
|
|
205
|
+
const selectedBatches = ref<string[]>([]);
|
|
206
|
+
const sortBy = ref<'score' | 'count' | 'lastSeen' | 'tag'>('score');
|
|
207
|
+
const minCount = ref<number>(0);
|
|
208
|
+
const search = ref<string | null>('');
|
|
209
|
+
|
|
210
|
+
const byScore = (a: TagEloRow, b: TagEloRow): number =>
|
|
211
|
+
a.score === null || b.score === null ? Number(a.score === null) - Number(b.score === null) : b.score - a.score;
|
|
212
|
+
const byLastSeen = (a: TagEloRow, b: TagEloRow): number => (b.lastSeen ?? '').localeCompare(a.lastSeen ?? '');
|
|
213
|
+
const SORTS: Record<typeof sortBy.value, (a: TagEloRow, b: TagEloRow) => number> = {
|
|
214
|
+
score: byScore,
|
|
215
|
+
count: (a, b) => b.count - a.count,
|
|
216
|
+
lastSeen: byLastSeen,
|
|
217
|
+
tag: (a, b) => a.tag.localeCompare(b.tag),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const visible = computed<TagEloRow[]>(() => {
|
|
221
|
+
const chosen = batchOptions.value.filter((o) => selectedBatches.value.includes(o.key)).map((o) => o.batch);
|
|
222
|
+
const q = search.value?.trim().toLowerCase();
|
|
223
|
+
const min = Number(minCount.value) || 0;
|
|
224
|
+
return (view.value?.rows ?? [])
|
|
225
|
+
.filter(
|
|
226
|
+
(r) =>
|
|
227
|
+
chosen.some((b) => matchesTagBatch(r.tag, b, r.role)) &&
|
|
228
|
+
r.count >= min &&
|
|
229
|
+
(!q || r.tag.toLowerCase().includes(q))
|
|
230
|
+
)
|
|
231
|
+
.sort((a, b) => SORTS[sortBy.value](a, b) || a.tag.localeCompare(b.tag));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/** Rows with a rating: the ones the charts can place. */
|
|
235
|
+
const plotted = computed(() => visible.value.filter((r) => r.score !== null));
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Picked tags: plotted as trajectories, each keeping its colour while picked
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
const MAX_PICKED = 8;
|
|
242
|
+
const picked = ref<Map<string, number>>(new Map());
|
|
243
|
+
|
|
244
|
+
function toggle(tag: string): void {
|
|
245
|
+
const next = new Map(picked.value);
|
|
246
|
+
if (next.has(tag)) next.delete(tag);
|
|
247
|
+
else {
|
|
248
|
+
if (next.size >= MAX_PICKED) return;
|
|
249
|
+
const used = new Set(next.values());
|
|
250
|
+
let slot = 0;
|
|
251
|
+
while (used.has(slot)) slot++;
|
|
252
|
+
next.set(tag, slot);
|
|
253
|
+
}
|
|
254
|
+
picked.value = next;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
watch(
|
|
258
|
+
() => props.dataset,
|
|
259
|
+
() => {
|
|
260
|
+
picked.value = new Map();
|
|
261
|
+
selectedBatches.value = batchOptions.value.filter((o) => !o.course && !isBookkeeping(o.batch)).map((o) => o.key);
|
|
262
|
+
},
|
|
263
|
+
{ immediate: true }
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Labels
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/** Short labels where they're unique among the tags in view, full tags where they aren't. */
|
|
271
|
+
const labels = computed(() => {
|
|
272
|
+
const short = new Map<string, string>();
|
|
273
|
+
const uses = new Map<string, number>();
|
|
274
|
+
for (const r of visible.value) {
|
|
275
|
+
const s = shortTag(props.interpreters, r.tag);
|
|
276
|
+
short.set(r.tag, s);
|
|
277
|
+
uses.set(s, (uses.get(s) ?? 0) + 1);
|
|
278
|
+
}
|
|
279
|
+
return new Map([...short].map(([tag, s]) => [tag, (uses.get(s) ?? 0) > 1 ? tag : s]));
|
|
280
|
+
});
|
|
281
|
+
const labelOf = (tag: string): string => labels.value.get(tag) ?? shortTag(props.interpreters, tag);
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Chart chrome: the admin palette's ink and categorical slots, per theme
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
const theme = useTheme();
|
|
288
|
+
const dark = computed(() => theme.current.value.dark);
|
|
289
|
+
|
|
290
|
+
const SLOTS = {
|
|
291
|
+
light: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#6250d6', '#e34948'],
|
|
292
|
+
dark: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'],
|
|
293
|
+
};
|
|
294
|
+
const INK = {
|
|
295
|
+
light: { primary: '#0b0b0b', secondary: '#52514e', muted: '#898781', grid: '#e1e0d9', axis: '#c3c2b7' },
|
|
296
|
+
dark: { primary: '#f0efec', secondary: '#c3c2b7', muted: '#898781', grid: '#2c2c2a', axis: '#383835' },
|
|
297
|
+
};
|
|
298
|
+
const ink = computed(() => INK[dark.value ? 'dark' : 'light']);
|
|
299
|
+
const slotColor = (slot: number): string => SLOTS[dark.value ? 'dark' : 'light'][slot];
|
|
300
|
+
const contextLine = computed(() => (dark.value ? 'rgba(195,194,183,0.18)' : 'rgba(82,81,78,0.16)'));
|
|
301
|
+
const surface = computed(() => theme.current.value.colors.surface);
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// ELO by tag (dot plot)
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
const ROW_PX = 20;
|
|
308
|
+
const dotHeight = computed(() => Math.max(160, plotted.value.length * ROW_PX + 72));
|
|
309
|
+
const dotSize = (count: number): number => Math.min(20, 8 + 2.2 * Math.log1p(count));
|
|
310
|
+
|
|
311
|
+
function escapeHtml(s: string): string {
|
|
312
|
+
return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function rowTooltip(r: TagEloRow): string {
|
|
316
|
+
const recent = r.recent.length ? `recent ${r.recentOk}/${r.recent.length} correct` : 'no recent buffer';
|
|
317
|
+
return [
|
|
318
|
+
`<b>${escapeHtml(r.tag)}</b>`,
|
|
319
|
+
`ELO ${r.score} (${signed(r.vsGlobal)} vs global) · ${r.count} responses`,
|
|
320
|
+
`${recent} · last seen ${fmtDate(r.lastSeen)}`,
|
|
321
|
+
].join('<br/>');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const dotOption = computed<EChartsCoreOption>(() => {
|
|
325
|
+
const rows = plotted.value;
|
|
326
|
+
const c = ink.value;
|
|
327
|
+
const global = view.value?.global.score ?? 0;
|
|
328
|
+
const scores = [global, ...rows.map((r) => r.score as number)];
|
|
329
|
+
const lo = Math.floor((Math.min(...scores) - 20) / 50) * 50;
|
|
330
|
+
const hi = Math.ceil((Math.max(...scores) + 20) / 50) * 50;
|
|
331
|
+
return {
|
|
332
|
+
animation: false,
|
|
333
|
+
grid: { left: 8, right: 16, top: 24, bottom: 8, containLabel: true },
|
|
334
|
+
tooltip: {
|
|
335
|
+
trigger: 'axis',
|
|
336
|
+
// The whole row band answers hover, not only the dot.
|
|
337
|
+
axisPointer: { type: 'shadow', axis: 'y' },
|
|
338
|
+
formatter: (params: unknown) => {
|
|
339
|
+
const i = (params as Array<{ dataIndex: number }>)[0]?.dataIndex;
|
|
340
|
+
return i === undefined ? '' : rowTooltip(rows[i]);
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
xAxis: {
|
|
344
|
+
type: 'value',
|
|
345
|
+
position: 'top',
|
|
346
|
+
min: lo,
|
|
347
|
+
max: hi,
|
|
348
|
+
axisLabel: { color: c.muted },
|
|
349
|
+
axisLine: { show: false },
|
|
350
|
+
splitLine: { lineStyle: { color: c.grid } },
|
|
351
|
+
},
|
|
352
|
+
yAxis: {
|
|
353
|
+
type: 'category',
|
|
354
|
+
inverse: true,
|
|
355
|
+
triggerEvent: true,
|
|
356
|
+
data: rows.map((r) => labelOf(r.tag)),
|
|
357
|
+
axisLabel: { color: c.secondary, fontSize: 11, interval: 0 },
|
|
358
|
+
axisTick: { show: false },
|
|
359
|
+
axisLine: { lineStyle: { color: c.axis } },
|
|
360
|
+
},
|
|
361
|
+
series: [
|
|
362
|
+
{
|
|
363
|
+
type: 'scatter',
|
|
364
|
+
data: rows.map((r, i) => {
|
|
365
|
+
const slot = picked.value.get(r.tag);
|
|
366
|
+
return {
|
|
367
|
+
name: r.tag,
|
|
368
|
+
value: [r.score, i],
|
|
369
|
+
symbolSize: dotSize(r.count),
|
|
370
|
+
itemStyle: {
|
|
371
|
+
color: slot === undefined ? c.muted : slotColor(slot),
|
|
372
|
+
borderColor: surface.value,
|
|
373
|
+
borderWidth: 2,
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}),
|
|
377
|
+
markLine: {
|
|
378
|
+
silent: true,
|
|
379
|
+
symbol: 'none',
|
|
380
|
+
lineStyle: { color: c.primary, type: 'solid', width: 1 },
|
|
381
|
+
label: { formatter: `global ${global}`, color: c.secondary, position: 'end' },
|
|
382
|
+
data: [{ xAxis: global }],
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
function onDotClick(e: ECElementEvent): void {
|
|
390
|
+
if (e.componentType === 'series' && typeof e.name === 'string') toggle(e.name);
|
|
391
|
+
else if (e.componentType === 'yAxis') {
|
|
392
|
+
const label = (e as unknown as { value?: string }).value;
|
|
393
|
+
const row = plotted.value.find((r) => labelOf(r.tag) === label);
|
|
394
|
+
if (row) toggle(row.tag);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Trajectories
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
const toData = (points: EloPoint[]): Array<[string, number]> => points.map((p) => [p.at, p.score]);
|
|
403
|
+
|
|
404
|
+
/** A step series' value at time t: its last point at or before t. */
|
|
405
|
+
function valueAt(points: EloPoint[], t: number): number | null {
|
|
406
|
+
let v: number | null = null;
|
|
407
|
+
for (const p of points) {
|
|
408
|
+
if (Date.parse(p.at) > t) break;
|
|
409
|
+
v = p.score;
|
|
410
|
+
}
|
|
411
|
+
return v;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const lineOption = computed<EChartsCoreOption>(() => {
|
|
415
|
+
const v = view.value;
|
|
416
|
+
const c = ink.value;
|
|
417
|
+
if (!v) return {};
|
|
418
|
+
const chosen = [...picked.value].filter(([tag]) => v.series.tags[tag]);
|
|
419
|
+
const context = plotted.value.filter((r) => !picked.value.has(r.tag) && v.series.tags[r.tag]);
|
|
420
|
+
const labelled = chosen.length <= 4;
|
|
421
|
+
const shown: Array<{ name: string; color: string; points: EloPoint[] }> = [
|
|
422
|
+
{ name: 'global', color: c.primary, points: v.series.global },
|
|
423
|
+
...chosen.map(([tag, slot]) => ({ name: labelOf(tag), color: slotColor(slot), points: v.series.tags[tag] })),
|
|
424
|
+
];
|
|
425
|
+
return {
|
|
426
|
+
animation: false,
|
|
427
|
+
grid: { left: 8, right: labelled ? 96 : 16, top: 36, bottom: 48, containLabel: true },
|
|
428
|
+
legend: {
|
|
429
|
+
top: 0,
|
|
430
|
+
data: shown.map((s) => s.name),
|
|
431
|
+
textStyle: { color: c.secondary },
|
|
432
|
+
selectedMode: false,
|
|
433
|
+
},
|
|
434
|
+
tooltip: {
|
|
435
|
+
trigger: 'axis',
|
|
436
|
+
formatter: (params: unknown) => {
|
|
437
|
+
const t = Number((params as Array<{ axisValue: number }>)[0]?.axisValue);
|
|
438
|
+
if (!Number.isFinite(t)) return '';
|
|
439
|
+
const lines = shown
|
|
440
|
+
.map((s) => ({ s, value: valueAt(s.points, t) }))
|
|
441
|
+
.filter((x) => x.value !== null)
|
|
442
|
+
.map(
|
|
443
|
+
({ s, value }) =>
|
|
444
|
+
`<span style="display:inline-block;width:8px;height:8px;border-radius:4px;margin-right:6px;background:${s.color}"></span>${escapeHtml(s.name)} ${value}`
|
|
445
|
+
);
|
|
446
|
+
return [fmtTime(new Date(t).toISOString()), ...lines].join('<br/>');
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
xAxis: {
|
|
450
|
+
type: 'time',
|
|
451
|
+
axisLabel: { color: c.muted },
|
|
452
|
+
axisLine: { lineStyle: { color: c.axis } },
|
|
453
|
+
splitLine: { show: false },
|
|
454
|
+
},
|
|
455
|
+
yAxis: {
|
|
456
|
+
type: 'value',
|
|
457
|
+
scale: true,
|
|
458
|
+
axisLabel: { color: c.muted },
|
|
459
|
+
splitLine: { lineStyle: { color: c.grid } },
|
|
460
|
+
},
|
|
461
|
+
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 16, bottom: 8 }],
|
|
462
|
+
series: [
|
|
463
|
+
...context.map((r) => ({
|
|
464
|
+
type: 'line',
|
|
465
|
+
name: r.tag,
|
|
466
|
+
data: toData(v.series.tags[r.tag]),
|
|
467
|
+
step: 'end',
|
|
468
|
+
showSymbol: false,
|
|
469
|
+
triggerLineEvent: true,
|
|
470
|
+
lineStyle: { width: 1, color: contextLine.value },
|
|
471
|
+
itemStyle: { color: contextLine.value },
|
|
472
|
+
emphasis: { lineStyle: { width: 2, color: c.muted } },
|
|
473
|
+
z: 1,
|
|
474
|
+
})),
|
|
475
|
+
...shown.map((s, i) => ({
|
|
476
|
+
type: 'line',
|
|
477
|
+
name: s.name,
|
|
478
|
+
data: toData(s.points),
|
|
479
|
+
step: 'end',
|
|
480
|
+
showSymbol: false,
|
|
481
|
+
triggerLineEvent: true,
|
|
482
|
+
lineStyle: { width: 2, color: s.color },
|
|
483
|
+
itemStyle: { color: s.color },
|
|
484
|
+
endLabel: { show: i === 0 || labelled, formatter: s.name, color: c.secondary },
|
|
485
|
+
z: i === 0 ? 3 : 4,
|
|
486
|
+
})),
|
|
487
|
+
],
|
|
488
|
+
};
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
function onLineClick(e: ECElementEvent): void {
|
|
492
|
+
if (e.componentType !== 'series' || typeof e.seriesName !== 'string') return;
|
|
493
|
+
// Context lines are named by tag; picked lines by label.
|
|
494
|
+
const tag =
|
|
495
|
+
view.value?.series.tags[e.seriesName] !== undefined
|
|
496
|
+
? e.seriesName
|
|
497
|
+
: [...picked.value.keys()].find((t) => labelOf(t) === e.seriesName);
|
|
498
|
+
if (tag) toggle(tag);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
// Display
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
function signed(d: number | null): string {
|
|
506
|
+
if (d === null) return '—';
|
|
507
|
+
return d > 0 ? `+${d}` : String(d);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function fmtDate(iso: string | null): string {
|
|
511
|
+
if (!iso) return '—';
|
|
512
|
+
const d = new Date(iso);
|
|
513
|
+
return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function staleTitle(r: TagEloRow): string {
|
|
517
|
+
return r.staleScore !== undefined
|
|
518
|
+
? 'counted, not rated: the bracketed score predates count-only roles and is ignored'
|
|
519
|
+
: 'counted, not rated';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const tiles = computed(() => {
|
|
523
|
+
const v = view.value;
|
|
524
|
+
const rated = (v?.rows ?? []).filter((r) => r.score !== null);
|
|
525
|
+
const gaps = plotted.value.map((r) => r.vsGlobal as number).sort((a, b) => a - b);
|
|
526
|
+
const median = gaps.length ? gaps[Math.floor(gaps.length / 2)] : null;
|
|
527
|
+
return [
|
|
528
|
+
{ label: `global ELO (${v?.global.count ?? 0} responses)`, value: v?.global.score ?? '—' },
|
|
529
|
+
{ label: 'rated tags in view', value: `${plotted.value.length} / ${rated.length}` },
|
|
530
|
+
{ label: 'median vs global, in view', value: signed(median) },
|
|
531
|
+
{ label: 'trajectories from', value: fmtDate(v?.seriesFrom ?? null) },
|
|
532
|
+
];
|
|
533
|
+
});
|
|
534
|
+
</script>
|
|
535
|
+
|
|
536
|
+
<style scoped>
|
|
537
|
+
.sk-tag-elo-sticky {
|
|
538
|
+
position: sticky;
|
|
539
|
+
top: 72px;
|
|
540
|
+
}
|
|
541
|
+
.sk-tag-elo-table tbody tr.sk-pickable {
|
|
542
|
+
cursor: pointer;
|
|
543
|
+
}
|
|
544
|
+
.sk-swatch {
|
|
545
|
+
display: inline-block;
|
|
546
|
+
width: 8px;
|
|
547
|
+
height: 8px;
|
|
548
|
+
border-radius: 4px;
|
|
549
|
+
margin-right: 6px;
|
|
550
|
+
vertical-align: middle;
|
|
551
|
+
}
|
|
552
|
+
.sk-ticks {
|
|
553
|
+
display: inline-flex;
|
|
554
|
+
align-items: center;
|
|
555
|
+
gap: 2px;
|
|
556
|
+
}
|
|
557
|
+
.sk-tick {
|
|
558
|
+
display: inline-block;
|
|
559
|
+
width: 5px;
|
|
560
|
+
height: 12px;
|
|
561
|
+
border-radius: 1px;
|
|
562
|
+
}
|
|
563
|
+
</style>
|
package/src/admin/index.ts
CHANGED
|
@@ -26,3 +26,4 @@ export { default as LearnerCardHistory } from './components/LearnerCardHistory.v
|
|
|
26
26
|
export { default as CardDossierDetail } from './components/CardDossierDetail.vue';
|
|
27
27
|
export { default as RunDetail } from './components/RunDetail.vue';
|
|
28
28
|
export { default as PipelineOverview } from './components/PipelineOverview.vue';
|
|
29
|
+
export { default as LearnerTagElo } from './components/LearnerTagElo.vue';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/base-course/CompositionViewable.ts
|
|
2
2
|
|
|
3
3
|
import moment from 'moment';
|
|
4
|
-
import {
|
|
4
|
+
import { customRef, ref, Ref } from 'vue';
|
|
5
5
|
import { CardRecord, QuestionRecord } from '@vue-skuilder/db';
|
|
6
6
|
import { HotKey } from '../utils/SkldrMouseTrap';
|
|
7
7
|
import { Question } from './Displayable';
|
|
@@ -11,7 +11,7 @@ import { ViewData, Answer } from '@vue-skuilder/common';
|
|
|
11
11
|
export interface ViewableUtils {
|
|
12
12
|
startTime: Ref<moment.Moment>;
|
|
13
13
|
hotKeys: Ref<HotKey[]>;
|
|
14
|
-
timeSpent:
|
|
14
|
+
timeSpent: Readonly<Ref<number>>;
|
|
15
15
|
logger: ViewableLogger;
|
|
16
16
|
getURL: (item: string, dataShapeIndex?: number) => string;
|
|
17
17
|
emitResponse: (record: CardRecord) => void;
|
|
@@ -63,7 +63,12 @@ export function useViewable(
|
|
|
63
63
|
console.warn(`[${componentName}]: `, message, ...params),
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
// Recomputed on every read. A computed caches its first read (moment.utc() isn't reactive),
|
|
67
|
+
// so retries were recorded with the first attempt's time.
|
|
68
|
+
const timeSpent = customRef<number>(() => ({
|
|
69
|
+
get: () => Math.abs(moment.utc().diff(startTime.value, 'milliseconds')),
|
|
70
|
+
set: () => {},
|
|
71
|
+
}));
|
|
67
72
|
|
|
68
73
|
const getURL = (item: string, dataShapeIndex = 0): string => {
|
|
69
74
|
try {
|
|
@@ -111,7 +116,8 @@ export function useQuestionView<Q extends Question>(
|
|
|
111
116
|
|
|
112
117
|
priorAnswers.value.push([answer, submittingClass ?? '']);
|
|
113
118
|
|
|
114
|
-
const
|
|
119
|
+
const timeSpent = viewableUtils.timeSpent.value;
|
|
120
|
+
const evaluation = question.value.evaluate(answer, timeSpent);
|
|
115
121
|
|
|
116
122
|
viewableUtils.logger.log(`evaluation of answer ${answer}:`, evaluation);
|
|
117
123
|
|
|
@@ -120,7 +126,7 @@ export function useQuestionView<Q extends Question>(
|
|
|
120
126
|
priorAttemps: priorAttempts.value,
|
|
121
127
|
courseID: '',
|
|
122
128
|
cardID: '',
|
|
123
|
-
timeSpent
|
|
129
|
+
timeSpent,
|
|
124
130
|
timeStamp: viewableUtils.startTime.value,
|
|
125
131
|
userAnswer: answer,
|
|
126
132
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DefineComponent, defineComponent } from 'vue';
|
|
2
|
-
import { FieldType, DataShape, ViewData, Answer, Evaluation } from '@vue-skuilder/common';
|
|
2
|
+
import { FieldType, DataShape, ViewData, Answer, Evaluation, SeedMeta } from '@vue-skuilder/common';
|
|
3
3
|
|
|
4
4
|
// [ ] #vue3 - post migration, specify this more precisely (no longer a hodge-podge)
|
|
5
5
|
export type ViewComponent =
|
|
@@ -31,6 +31,12 @@ export abstract class Displayable {
|
|
|
31
31
|
*/
|
|
32
32
|
public static acceptsUserData: boolean = true;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Optional tags and starting ELO for the cards seeded from one seedData
|
|
36
|
+
* item. Without it, seeded cards start untagged at ~1000.
|
|
37
|
+
*/
|
|
38
|
+
public static seedMeta?: (item: unknown) => SeedMeta;
|
|
39
|
+
|
|
34
40
|
/**
|
|
35
41
|
*
|
|
36
42
|
*/
|