diff --git a/packages/ilios-common/addon/components/course/visualizations.hbs b/packages/ilios-common/addon/components/course/visualizations.hbs
index 3c09c142d2..dcf705db43 100644
--- a/packages/ilios-common/addon/components/course/visualizations.hbs
+++ b/packages/ilios-common/addon/components/course/visualizations.hbs
@@ -53,7 +53,7 @@
{{t "general.vocabularies"}}
-
+
diff --git a/packages/ilios-common/addon/components/course/visualize-vocabularies-graph.hbs b/packages/ilios-common/addon/components/course/visualize-vocabularies-graph.hbs
index 3a761a5c1d..9edebf446b 100644
--- a/packages/ilios-common/addon/components/course/visualize-vocabularies-graph.hbs
+++ b/packages/ilios-common/addon/components/course/visualize-vocabularies-graph.hbs
@@ -6,12 +6,12 @@
{{#if this.isLoaded}}
{{#if (or @isIcon this.data.length)}}
{{#if this.tooltipContent}}
@@ -21,4 +21,55 @@
{{/if}}
{{/if}}
+ {{#if (and (not @isIcon) @showDataTable)}}
+
+
+
+
+
+ {{t "general.vocabulary"}}
+
+
+ {{t "general.sessions"}}
+
+
+ {{t "general.minutes"}}
+
+
+
+
+ {{#each (sort-by this.sortBy this.tableData) as |row|}}
+
+ {{row.vocabulary}} |
+
+ {{#each row.sessions as |session index|}}
+
+ {{session.title~}}
+ {{if (not-eq index (sub row.sessions.length 1)) ","}}
+ {{/each}}
+ |
+ {{row.minutes}} |
+
+ {{/each}}
+
+
+
+ {{/if}}
diff --git a/packages/ilios-common/addon/components/course/visualize-vocabularies-graph.js b/packages/ilios-common/addon/components/course/visualize-vocabularies-graph.js
index 5b891eb2fd..feefa790ec 100644
--- a/packages/ilios-common/addon/components/course/visualize-vocabularies-graph.js
+++ b/packages/ilios-common/addon/components/course/visualize-vocabularies-graph.js
@@ -5,16 +5,15 @@ import { restartableTask, timeout } from 'ember-concurrency';
import { service } from '@ember/service';
import { cached, tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
-import { use } from 'ember-could-get-used-to-this';
import { TrackedAsyncData } from 'ember-async-data';
-import AsyncProcess from 'ilios-common/classes/async-process';
-import { findBy, mapBy } from 'ilios-common/utils/array-helpers';
+import { findById, mapBy, uniqueById } from 'ilios-common/utils/array-helpers';
export default class CourseVisualizeVocabulariesGraph extends Component {
@service router;
@service intl;
@tracked tooltipContent = null;
@tracked tooltipTitle = null;
+ @tracked sortBy = 'minutes';
@cached
get sessionsData() {
@@ -25,16 +24,47 @@ export default class CourseVisualizeVocabulariesGraph extends Component {
return this.sessionsData.isResolved ? this.sessionsData.value : [];
}
- @use dataObjects = new AsyncProcess(() => [this.getDataObjects.bind(this), this.sessions]);
+ @cached
+ get outputData() {
+ return new TrackedAsyncData(this.getDataObjects(this.sessions));
+ }
+
+ get data() {
+ return this.outputData.isResolved ? this.outputData.value : [];
+ }
+
+ get tableData() {
+ return this.data.map((obj) => {
+ const rhett = {};
+ rhett.minutes = obj.data;
+ rhett.sessions = obj.meta.sessions;
+ rhett.vocabulary = obj.meta.vocabulary.title;
+ rhett.sessionTitles = mapBy(rhett.sessions, 'title').join(', ');
+ return rhett;
+ });
+ }
get isLoaded() {
- return !!this.dataObjects;
+ return this.outputData.isResolved;
+ }
+
+ get sortedAscending() {
+ return this.sortBy.search(/desc/) === -1;
+ }
+
+ @action
+ setSortBy(prop) {
+ if (this.sortBy === prop) {
+ prop += ':desc';
+ }
+ this.sortBy = prop;
}
async getDataObjects(sessions) {
- if (!sessions) {
+ if (!sessions.length) {
return [];
}
+
const sessionsWithMinutes = await map(sessions.slice(), async (session) => {
const hours = await session.getTotalSumDuration();
return {
@@ -42,56 +72,73 @@ export default class CourseVisualizeVocabulariesGraph extends Component {
minutes: Math.round(hours * 60),
};
});
- return map(sessionsWithMinutes, async ({ session, minutes }) => {
- const terms = (await session.terms).slice();
- const vocabularies = await all(mapBy(terms, 'vocabulary'));
- return {
- sessionTitle: session.title,
- vocabularies,
- minutes,
- };
- });
- }
- get data() {
- return this.dataObjects.reduce((set, obj) => {
- obj.vocabularies.forEach((vocabulary) => {
- const vocabularyTitle = vocabulary.get('title');
- let existing = findBy(set, 'label', vocabularyTitle);
- if (!existing) {
- existing = {
- data: 0,
- label: vocabularyTitle,
- meta: {
- vocabulary,
- sessions: [],
- },
- };
- set.push(existing);
- }
- existing.data += obj.minutes;
- existing.meta.sessions.push(obj.sessionTitle);
- });
+ const sessionWithMinutesAndVocabs = await map(
+ sessionsWithMinutes,
+ async ({ session, minutes }) => {
+ const terms = (await session.terms).slice();
+ const vocabularies = await all(mapBy(terms, 'vocabulary'));
+ return {
+ session,
+ vocabularies: uniqueById(vocabularies),
+ minutes,
+ };
+ },
+ );
+
+ return sessionWithMinutesAndVocabs
+ .reduce((set, obj) => {
+ obj.vocabularies.forEach((vocabulary) => {
+ const id = vocabulary.id;
+ let existing = findById(set, id);
+ if (!existing) {
+ existing = {
+ id,
+ data: 0,
+ label: vocabulary.title,
+ meta: {
+ vocabulary,
+ sessions: [],
+ },
+ };
+ set.push(existing);
+ }
+ existing.data += obj.minutes;
+ existing.meta.sessions.push(obj.session);
+ });
- return set;
- }, []);
+ return set;
+ }, [])
+ .map((obj) => {
+ delete obj.id;
+ return obj;
+ })
+ .sort((first, second) => {
+ return first.data - second.data;
+ });
}
- donutHover = restartableTask(async (obj) => {
+ barHover = restartableTask(async (obj) => {
await timeout(100);
if (this.args.isIcon || !obj || obj.empty) {
this.tooltipTitle = null;
this.tooltipContent = null;
return;
}
- const { meta } = obj;
+ const { data, meta } = obj;
+
+ const title = htmlSafe(
+ `${meta.vocabulary.title} • ${data} ${this.intl.t('general.minutes')}`,
+ );
+ const sessionTitles = mapBy(meta.sessions, 'title');
+ const content = sessionTitles.sort().join(', ');
- this.tooltipTitle = htmlSafe(meta.vocabulary.get('title'));
- this.tooltipContent = this.intl.t('general.clickForMore');
+ this.tooltipTitle = title;
+ this.tooltipContent = content;
});
@action
- donutClick(obj) {
+ barClick(obj) {
if (this.args.isIcon || !obj || obj.empty || !obj.meta) {
return;
}
diff --git a/packages/ilios-common/addon/components/course/visualize-vocabularies.hbs b/packages/ilios-common/addon/components/course/visualize-vocabularies.hbs
index 4845865d9c..537e4fdee7 100644
--- a/packages/ilios-common/addon/components/course/visualize-vocabularies.hbs
+++ b/packages/ilios-common/addon/components/course/visualize-vocabularies.hbs
@@ -32,6 +32,6 @@
-
+
diff --git a/packages/ilios-common/app/styles/ilios-common/components/course/visualize-vocabularies-graph.scss b/packages/ilios-common/app/styles/ilios-common/components/course/visualize-vocabularies-graph.scss
index f8c3f14484..2337373326 100644
--- a/packages/ilios-common/app/styles/ilios-common/components/course/visualize-vocabularies-graph.scss
+++ b/packages/ilios-common/app/styles/ilios-common/components/course/visualize-vocabularies-graph.scss
@@ -1,11 +1,35 @@
+@use "../../colors" as c;
+@use "../../mixins" as m;
+
.course-visualize-vocabularies-graph {
display: inline-block;
height: 1rem;
width: 1rem;
+ .data-table {
+ grid-column: -1/1;
+ padding-top: 2rem;
+
+ table {
+ @include m.ilios-table-structure;
+ @include m.ilios-table-colors;
+ @include m.ilios-removable-table;
+ @include m.ilios-zebra-table;
+
+ thead {
+ background-color: c.$culturedGrey;
+ }
+
+ td {
+ vertical-align: top;
+ }
+ }
+ }
+
&.not-icon {
- height: 75vh;
- width: 75vw;
+ display: grid;
+ height: auto;
+ width: auto;
.simple-chart-tooltip {
.title {