Skip to content

Commit

Permalink
completely redefines course/vocab viz as bar chart and adds data tabl…
Browse files Browse the repository at this point in the history
…e 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.
  • Loading branch information
stopfstedt committed Jul 11, 2024
1 parent 0f23edd commit 19e95c0
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
<h4>
{{t "general.vocabularies"}}
</h4>
<Course::VisualizeVocabulariesGraph @isIcon={{true}} @course={{@model}} />
<Course::VisualizeVocabulariesGraph @isIcon={{true}} @course={{@model}} @showDataTable={{false}} />
</LinkTo>
</div>
<div data-test-visualize-instructors>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
{{#if this.isLoaded}}
{{#if (or @isIcon this.data.length)}}
<SimpleChart
@name="donut"
@name="horz-bar"
@isIcon={{@isIcon}}
@data={{this.data}}
@onClick={{this.donutClick}}
@hover={{perform this.donutHover}}
@leave={{perform this.donutHover}} as |chart|
@onClick={{this.barClick}}
@hover={{perform this.barHover}}
@leave={{perform this.barHover}} as |chart|
>
{{#if this.tooltipContent}}
<chart.tooltip @title={{this.tooltipTitle}}>
Expand All @@ -21,4 +21,55 @@
</SimpleChart>
{{/if}}
{{/if}}
{{#if (and (not @isIcon) @showDataTable)}}
<div class="data-table" data-test-data-table>
<table>
<thead>
<tr>
<SortableTh
@sortedAscending={{this.sortedAscending}}
@sortedBy={{or (eq this.sortBy "vocabulary") (eq this.sortBy "vocabulary:desc")}}
@onClick={{fn this.setSortBy "vocabulary"}}
data-test-vocabulary
>
{{t "general.vocabulary"}}
</SortableTh>
<SortableTh
@colspan="2"
@sortedAscending={{this.sortedAscending}}
@sortedBy={{or (eq this.sortBy "sessionTitles") (eq this.sortBy "sessionTitles:desc")}}
@onClick={{fn this.setSortBy "sessionTitles"}}
data-test-sessions
>
{{t "general.sessions"}}
</SortableTh>
<SortableTh
@sortedAscending={{this.sortedAscending}}
@sortedBy={{or (eq this.sortBy "minutes") (eq this.sortBy "minutes:desc")}}
@onClick={{fn this.setSortBy "minutes"}}
@sortType="numeric"
data-test-minutes
>
{{t "general.minutes"}}
</SortableTh>
</tr>
</thead>
<tbody>
{{#each (sort-by this.sortBy this.tableData) as |row|}}
<tr>
<td data-test-vocabulary>{{row.vocabulary}}</td>
<td colspan="2" data-test-sessions>
{{#each row.sessions as |session index|}}
<LinkTo @route="session" @models={{array @course session}}>
{{session.title~}}
</LinkTo>{{if (not-eq index (sub row.sessions.length 1)) ","}}
{{/each}}
</td>
<td data-test-minutes>{{row.minutes}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -25,73 +24,121 @@ 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 {
session,
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} &bull; ${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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@
</LinkTo>
</h3>
<div class="visualizations">
<Course::VisualizeVocabulariesGraph @course={{@model}} />
<Course::VisualizeVocabulariesGraph @course={{@model}} @showDataTable={{true}} />
</div>
</section>
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down

0 comments on commit 19e95c0

Please sign in to comment.