From 19e95c0694a978b3b3c7b5c4774dd5efe4ccfb10 Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Tue, 9 Jul 2024 16:12:42 -0700 Subject: [PATCH] completely redefines course/vocab viz as bar chart and adds data table to output. forcing this data into a donut chart gives an inaccurate representation - since vocabs can overlap on assigned offering/ilm time, there is no 100% and therefore no pie to slice. using a horizontal bar chart seems more appropriate. --- .../components/course/visualizations.hbs | 2 +- .../course/visualize-vocabularies-graph.hbs | 59 +++++++- .../course/visualize-vocabularies-graph.js | 131 ++++++++++++------ .../course/visualize-vocabularies.hbs | 2 +- .../course/visualize-vocabularies-graph.scss | 28 +++- 5 files changed, 172 insertions(+), 50 deletions(-) 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|}} + + + + + + {{/each}} + +
{{row.vocabulary}} + {{#each row.sessions as |session index|}} + + {{session.title~}} + {{if (not-eq index (sub row.sessions.length 1)) ","}} + {{/each}} + {{row.minutes}}
+
+ {{/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 {