From 9624a139d681e341a1125bcbb9cc2ba363b5ee91 Mon Sep 17 00:00:00 2001 From: Michael Kelly <20041540@mail.wit.ie> Date: Sat, 17 Aug 2024 11:12:40 +0100 Subject: [PATCH] Implement dry version of heatmap charts --- .../ui/time/supabase/analytics/calendar.ts | 4 +- .../analytics/heatmap/base-heat-map.ts | 256 ++++++++++++ .../analytics/heatmap/lab-heat-map-chart.ts | 29 ++ .../analytics/heatmap/topic-heat-map-chart.ts | 28 ++ .../time/supabase/analytics/lab-heat-map.ts | 363 ------------------ src/lib/ui/time/supabase/analytics/lab-pie.ts | 151 -------- .../analytics/piechart/base-pie-chart.ts | 3 +- .../analytics/piechart/lab-pie-chart.ts | 6 +- .../analytics/piechart/topic-pie-chart.ts | 6 +- .../time/supabase/analytics/topic-heat-map.ts | 348 ----------------- .../ui/time/supabase/analytics/topic-pie.ts | 194 ---------- .../ui/time/supabase/charts/heatmap-chart.ts | 73 +++- src/lib/ui/time/supabase/views/Chart.svelte | 3 + .../time/supabase/views/HeatMapChart.svelte | 58 +++ .../supabase/views/InstructorLabView.svelte | 40 +- .../supabase/views/InstructorTopicView.svelte | 50 +-- src/lib/ui/time/supabase/views/LabView.svelte | 46 +-- .../ui/time/supabase/views/TopicView.svelte | 50 +-- .../(time)/next-time/[courseid]/+page.svelte | 167 +++++--- 19 files changed, 602 insertions(+), 1273 deletions(-) create mode 100644 src/lib/ui/time/supabase/analytics/heatmap/base-heat-map.ts create mode 100644 src/lib/ui/time/supabase/analytics/heatmap/lab-heat-map-chart.ts create mode 100644 src/lib/ui/time/supabase/analytics/heatmap/topic-heat-map-chart.ts delete mode 100644 src/lib/ui/time/supabase/analytics/lab-heat-map.ts delete mode 100644 src/lib/ui/time/supabase/analytics/lab-pie.ts delete mode 100644 src/lib/ui/time/supabase/analytics/topic-heat-map.ts delete mode 100644 src/lib/ui/time/supabase/analytics/topic-pie.ts create mode 100644 src/lib/ui/time/supabase/views/HeatMapChart.svelte diff --git a/src/lib/ui/time/supabase/analytics/calendar.ts b/src/lib/ui/time/supabase/analytics/calendar.ts index 82f199df1..adf2facbf 100644 --- a/src/lib/ui/time/supabase/analytics/calendar.ts +++ b/src/lib/ui/time/supabase/analytics/calendar.ts @@ -10,7 +10,7 @@ import { tutorsAnalyticsLogo } from "../charts/personlised-logo"; import type { CalendarMap } from "$lib/services/types/supabase-metrics"; import type { Course } from "$lib/services/models/lo-types"; import type { Session } from "@supabase/supabase-js"; - import { getGithubAvatarUrl } from "$lib/services/utils/supabase-utils"; +import { getGithubAvatarUrl } from "$lib/services/utils/supabase-utils"; import { generateStudent } from "../../../../../routes/(time)/simulate/generateStudent"; echarts.use([TitleComponent, CalendarComponent, TooltipComponent, VisualMapComponent, HeatmapChart, CanvasRenderer, GraphicComponent]); @@ -134,7 +134,7 @@ export class CalendarChart { //const fullname = (await getUser(userId)) || userId; //real //const fullname = (await generateStudent()).fullName; //fake - + //const student = await generateStudent(); //generate fake student // const avatarUrl = await getGithubAvatarUrl(userId); // const fullName = await getUser(userId); diff --git a/src/lib/ui/time/supabase/analytics/heatmap/base-heat-map.ts b/src/lib/ui/time/supabase/analytics/heatmap/base-heat-map.ts new file mode 100644 index 000000000..59a6ebb69 --- /dev/null +++ b/src/lib/ui/time/supabase/analytics/heatmap/base-heat-map.ts @@ -0,0 +1,256 @@ +import * as echarts from "echarts/core"; +import { TooltipComponent, GridComponent, VisualMapComponent } from "echarts/components"; +import { HeatmapChart } from "echarts/charts"; +import { CanvasRenderer } from "echarts/renderers"; +import { backgroundPattern } from "../../charts/tutors-charts-background-url"; +import type { Course, Lo } from "$lib/services/models/lo-types"; +import type { Session } from "@supabase/supabase-js"; +import type { HeatMapSeriesData, HeatMapChartConfig } from "$lib/services/types/supabase-metrics"; +import { heatmap, renderCombinedChart } from "../../charts/heatmap-chart"; + +echarts.use([TooltipComponent, GridComponent, VisualMapComponent, HeatmapChart, CanvasRenderer]); + +const bgPatternImg = new Image(); +bgPatternImg.src = backgroundPattern; + +export class BaseHeatMapChart { + chartRendered = false; + chartInstances: Map; + course: Course; + session: Session; + userIds: string[]; + userNamesUseridsMap: Map; + chartInstance: echarts.ECharts | null = null; + categories: Set = new Set(); + yAxisData: string[] = []; + series: HeatMapSeriesData = { + top: "", + name: "", + data: [], + type: "heatmap", + selectedMode: "single", + label: { + show: true + } + }; + multipleUsers: boolean; + + constructor(course: Course, session: Session, userIds: string[], userNamesUseridsMap: Map, multipleUsers: boolean) { + this.chartInstances = new Map(); + this.course = course; + this.session = session; + this.userIds = userIds; + this.userNamesUseridsMap = userNamesUseridsMap; + this.multipleUsers = multipleUsers; + } + + initChart() { + if (!this.chartInstance) { + // Create a new chart instance if it doesn't exist + this.chartInstance = echarts.init(document.getElementById("chart")); + } else { + // Clear the previous chart to prevent aggregation issues + this.chartInstance.clear(); + } + } + + getChartContainer() { + const container = document.getElementById("heatmap-container"); + return container; + } + + getCombinedChartContainer() { + const container = document.getElementById("combined-heatmap"); + return container; + } + + async getUserFullName(userId: string) { + return this.userNamesUseridsMap.get(userId) || userId; + } + + async populatePerUserSeriesData(allItems: Lo[], userId: string, index: number, learninObjValue: string): Promise { + const totalTimesMap = new Map(); + const titleList: string[] = []; + allItems.forEach((item) => { + let title: string = ""; + if (learninObjValue === "lab") { + title = item.parentLo?.type === "lab" ? item.parentLo?.title : item.title; + } else { + if (item.parentTopic?.type === "topic") { + title = item.parentTopic?.title; + } else if (item.parentLo?.parentTopic?.type === "topic") { + title = item.parentLo?.parentTopic?.title; + } else { + title = item.title; + } + } + + const timeActive = item.learningRecords?.get(userId)?.timeActive || 0; + // Add timeActive to the total time for the step + if (totalTimesMap.has(title)) { + totalTimesMap.set(title, totalTimesMap.get(title)! + timeActive); + titleList.push(title); + } else { + totalTimesMap.set(title, timeActive); + titleList.push(title.trim()); + } + }); + + this.categories = new Set(Array.from(totalTimesMap.keys())); + const categoriesArray = Array.from(this.categories); + + // Construct seriesData array using the aggregated total times + const seriesData: number[][] = Array.from(totalTimesMap.entries()).map(([title, timeActive], stepIndex) => { + //return [titleList.indexOf(title.trim()), index, Math.round(timeActive / 2)]; + return [categoriesArray.indexOf(title), index, Math.floor(timeActive / 2)]; + }); + + //const userFullName = await getUser(userId) || userId; + + return seriesData; + } + + async populateAndRenderUsersData(allItems: Lo[], userIds: string[], learninObjValue: string) { + const container = this.getChartContainer(); + if (!container) return; + + let allSeriesData: number[][] = []; + const yAxisData: string[] = []; + + for (const [index, userId] of userIds.entries()) { + const seriesData = await this.populatePerUserSeriesData(allItems, userId, index, learninObjValue); + allSeriesData = allSeriesData.concat(seriesData); + const fullName = await this.getUserFullName(userId); + yAxisData.push(fullName); + } + + this.series = { + name: `lab activity for all users`, + type: "heatmap", + data: allSeriesData, + selectedMode: "single", + top: "5%", + label: { + show: true + } + }; + + this.yAxisData = yAxisData; + this.renderChart(container, ""); + } + + async populateAndRenderSingleUserData(session: Session, allItems: Lo[], learninObjValue: string) { + const container = this.getChartContainer(); + if (!container) return; + + const userId = session.user.user_metadata.full_name ?? session.user.user_metadata.user_name; + this.yAxisData = [userId]; + + const seriesData: number[][] = await this.populatePerUserSeriesData(allItems, session.user.user_metadata.user_name, 0, learninObjValue.valueOf()); + this.series = { + top: "5%", + name: `${learninObjValue.valueOf()} Activity`, + type: "heatmap", + data: seriesData, + selectedMode: "single", + label: { + show: true + } + }; + + this.renderChart(container, ""); + } + + renderChart(container: HTMLElement, title: string) { + this.chartInstance = echarts.init(container); + const option: HeatMapChartConfig = heatmap(this.categories, this.yAxisData, this.series, bgPatternImg, title); + this.chartInstance.setOption(option); + this.chartInstance.resize(); + this.sortHeatMapValues(); + } + + prepareCombinedTopicData(allTypes: Lo[], userIds: string[], getTitle: (lo: Lo) => string) { + const loActivities = new Map(); + const container = this.getCombinedChartContainer(); + if (!container) return; + allTypes.forEach((lo) => { + const title = getTitle(lo); + if (!loActivities.has(title)) { + loActivities.set(title, []); + } + + lo.learningRecords?.forEach((topic, userId) => { + if (userIds.includes(userId)) { + loActivities.get(title).push({ + timeActive: topic.timeActive, + nickname: userId + }); + } + }); + }); + + const heatmapData = Array.from(loActivities.entries()).map(([title, activities]) => { + activities.sort((a: { timeActive: number }, b: { timeActive: number }) => a.timeActive - b.timeActive); + + const addedCount = activities.reduce((acc: number, curr: { timeActive: any }) => acc + curr.timeActive, 0); + + const lowData = activities[0]; + const highData = activities[activities.length - 1]; + + return { + value: addedCount, + title: title, + lowValue: lowData?.timeActive || 0, + highValue: highData?.timeActive || 0, + lowNickname: lowData?.nickname || "No Interaction", + highNickname: highData?.nickname || "No Interaction" + }; + }); + this.renderCombinedTopicChart(container, heatmapData, "Aggregated Time"); + } + + renderCombinedTopicChart(container: HTMLElement, heatmapData: any[], title: string) { + const chartInstance = echarts.init(container); + const option = renderCombinedChart(heatmapData, bgPatternImg, title); + chartInstance.setOption(option); + chartInstance.resize(); + this.sortHeatMapValues(); + } + + async sortHeatMapValues() { + if (this.chartInstance !== null) { + this.chartInstance.off("click"); + this.chartInstance.on("click", async (params: { componentType: string; seriesType: string; value: any[] }) => { + if (params.componentType === "series" && params.seriesType === "heatmap") { + const colIndex = params.value[0]; // Column index of the clicked cell + // Extract the data for the clicked column + let columnData = this.series.data.filter((item: any[]) => item[0] === colIndex); + // Sort the column data by the value (timeActive) in ascending order + columnData.sort((a: number[], b: number[]) => a[2] - b[2]); + // Reorder yAxisData based on sorted column data + const sortedUserIndices = columnData.map((item: any[]) => item[1]); + const sortedYAxisData = sortedUserIndices.map((index: string | number) => this.yAxisData[index]); + // Reconstruct the series data with sorted y-axis order + let newData = this.series.data.map((item: any[]) => { + const newIndex = sortedUserIndices.indexOf(item[1]); + return [item[0], newIndex, item[2]]; + }); + // Update the y-axis data and series data + this.yAxisData = sortedYAxisData; + this.series.data = newData; + // Refresh the chart instance + this.chartInstance?.setOption({ + yAxis: { + data: this.yAxisData + }, + series: [ + { + data: this.series.data + } + ] + }); + } + }); + } + } +} diff --git a/src/lib/ui/time/supabase/analytics/heatmap/lab-heat-map-chart.ts b/src/lib/ui/time/supabase/analytics/heatmap/lab-heat-map-chart.ts new file mode 100644 index 000000000..f32124f34 --- /dev/null +++ b/src/lib/ui/time/supabase/analytics/heatmap/lab-heat-map-chart.ts @@ -0,0 +1,29 @@ +import { BaseHeatMapChart } from "./base-heat-map"; +import { filterByType } from "$lib/services/models/lo-utils"; +import type { Course, Lo } from "$lib/services/models/lo-types"; +import type { Session } from "@supabase/supabase-js"; + +export class LabHeatMapChart extends BaseHeatMapChart { + labs: Lo[]; + + constructor(course: any, session: Session, userIds: string[], userNamesUseridsMap: Map, multipleUsers: boolean) { + super(course, session, userIds, userNamesUseridsMap, multipleUsers); + let labs = filterByType(course.los, "lab"); + let steps = filterByType(course.los, "step"); + + this.labs = [...labs, ...steps]; + } + + async populateAndRenderData() { + if (this.multipleUsers) { + await this.populateAndRenderUsersData(this.labs, this.userIds, "lab"); + this.prepareCombinedTopicData(this.labs, this.userIds, (lo) => (lo.type === "lab" ? lo.title : lo.parentLo!.title)); + } else { + await this.populateAndRenderSingleUserData(this.session, this.labs, "lab"); + } + } + + renderChart(container: HTMLElement) { + super.renderChart(container, "Lab Activity: Per Student (click a cell to sort)"); + } +} diff --git a/src/lib/ui/time/supabase/analytics/heatmap/topic-heat-map-chart.ts b/src/lib/ui/time/supabase/analytics/heatmap/topic-heat-map-chart.ts new file mode 100644 index 000000000..6d384388a --- /dev/null +++ b/src/lib/ui/time/supabase/analytics/heatmap/topic-heat-map-chart.ts @@ -0,0 +1,28 @@ +import { BaseHeatMapChart } from "./base-heat-map"; +import { getCompositeValues, getSimpleTypesValues } from "$lib/services/utils/supabase-utils"; +import type { Course, Lo } from "$lib/services/models/lo-types"; +import type { Session } from "@supabase/supabase-js"; + +export class TopicHeatMapChart extends BaseHeatMapChart { + topics: Lo[]; + + constructor(course: Course, session: Session, userIds: string[], userNamesUseridsMap: Map, multipleUsers: boolean) { + super(course, session, userIds, userNamesUseridsMap, multipleUsers); + this.topics = getCompositeValues(course.los).concat(getSimpleTypesValues(course.los)); + } + + async populateAndRenderData() { + if (this.multipleUsers) { + await this.populateAndRenderUsersData(this.topics, this.userIds, "topic"); + this.prepareCombinedTopicData(this.topics, this.userIds, (lo) => + lo.parentTopic?.type === "topic" ? lo.parentTopic.title : lo.parentLo?.parentTopic?.type === "topic" ? lo.parentLo?.parentTopic?.title : lo.title + ); + } else { + await this.populateAndRenderSingleUserData(this.session, this.topics, "topic"); + } + } + + renderChart(container: HTMLElement) { + super.renderChart(container, "Topic Activity: Per Student (click a cell to sort)"); + } +} diff --git a/src/lib/ui/time/supabase/analytics/lab-heat-map.ts b/src/lib/ui/time/supabase/analytics/lab-heat-map.ts deleted file mode 100644 index 7934780f4..000000000 --- a/src/lib/ui/time/supabase/analytics/lab-heat-map.ts +++ /dev/null @@ -1,363 +0,0 @@ -import * as echarts from "echarts/core"; -import { TooltipComponent, GridComponent, VisualMapComponent } from "echarts/components"; -import { HeatmapChart } from "echarts/charts"; -import { CanvasRenderer } from "echarts/renderers"; -import { backgroundPattern } from "../charts/tutors-charts-background-url"; -import { heatmap } from "../charts/heatmap-chart"; -import type { Course, Lo } from "$lib/services/models/lo-types"; -import type { Session } from "@supabase/supabase-js"; -import { filterByType } from "$lib/services/models/lo-utils"; -import type { HeatMapSeriesData, HeatMapChartConfig } from "$lib/services/types/supabase-metrics"; -// import { getUser } from "$lib/services/utils/supabase-utils"; -import { generateStudent } from "../../../../../routes/(time)/simulate/generateStudent"; -import type { ECharts } from "echarts"; - -echarts.use([TooltipComponent, GridComponent, VisualMapComponent, HeatmapChart, CanvasRenderer]); - -const bgPatternImg = new Image(); -bgPatternImg.src = backgroundPattern; - -export class LabHeatMapChart { - chartRendered: boolean = false; - chartInstances: Map; - labs: Lo[] | undefined; - categories: Set; - yAxisData: string[]; - course: Course; - session: Session; - series: HeatMapSeriesData; - los: Lo[]; - userIds: string[]; - chartInstance: ECharts; - userNamesUseridsMap: Map; - - constructor(course: Course, session: Session, userIds: string[], userNamesUseridsMap: Map) { - this.chartRendered = false; - this.chartInstances = new Map(); - this.labs = course.wallMap?.get("lab"); // Array of lab titles - this.userIds = userIds; - this.categories = new Set(); - this.yAxisData = []; - this.series = { - top: "", - name: "", - data: [], - type: "heatmap", - selectedMode: "single", - label: { - show: true - } - }; - this.course = course; - this.session = session; - this.los = filterByType(this.course.los, "lab"); - this.chartInstance = null; - this.userNamesUseridsMap = userNamesUseridsMap; - } - - populateUsersData() { - if (this.labs) { - this.populateLabTitles(this.labs); - this.populateAndRenderUsersData(this.course, this.labs, this.userIds); - } - } - - async populateSingleUserData() { - if (this.labs) { - this.populateLabTitles(this.labs); - this.populateAndRenderSingleUserData(this.session, this.labs); - } - } - - populateLabTitles(allLabs: Lo[]) { - const labTitles = allLabs.map((lab) => lab.title.trim()); - this.categories = new Set(labTitles); - } - - getChartContainer() { - const container = document.getElementById("heatmap-container"); - if (container) { - container.style.width = "100%"; - container.style.height = "100%"; - } - return container; - } - - async populatePerUserSeriesData(course: Course, allLabs: Lo[], userId: string, index: number = 0): Promise { - const labTitles = allLabs.map((lab: { title: string }) => lab.title.trim()); - this.categories = new Set(labTitles); - - let labs = filterByType(course.los, "lab"); - let steps = filterByType(course.los, "step"); - - const allLabSteps = [...labs, ...steps]; - - // Map to store total timeActive for each step - const totalTimesMap = new Map(); - - // Iterate over allLabSteps to aggregate total timeActive for each step - allLabSteps.forEach((step, stepIndex) => { - const title = step.parentLo?.type === "lab" ? step.parentLo?.title : step.title; - const timeActive = step.learningRecords?.get(userId)?.timeActive || 0; - - // Add timeActive to the total time for the step - if (totalTimesMap.has(title)) { - totalTimesMap.set(title, totalTimesMap.get(title)! + timeActive); - } else { - totalTimesMap.set(title, timeActive); - } - }); - - // Construct seriesData array using the aggregated total times - const seriesData: number[][] = Array.from(totalTimesMap.entries()).map(([title, timeActive], stepIndex) => { - return [labTitles.indexOf(title.trim()), index, Math.round(timeActive / 2)]; - }); - - //const userFullName = await getUser(userId) || userId; - - return seriesData; - } - - async populateAndRenderUsersData(course: Course, allLabs: Lo[], userIds: string[]) { - const container = this.getChartContainer(); - if (!container) return; - - let allSeriesData: number[][] = []; // Array to store all series data - let yAxisData: string[] = []; // Array to store yAxis data - - const labTitles = allLabs.map((lab: { title: string }) => lab.title.trim()); - this.categories = new Set(labTitles); - let seriesData: number[][]; - for (const [index, userId] of userIds.entries()) { - seriesData = await this.populatePerUserSeriesData(course, allLabs, userId, index); - allSeriesData = allSeriesData.concat(seriesData); - - if (!yAxisData.includes(userId)) { - const fullName = this.userNamesUseridsMap.get(userId) || userId; - //const fullname = (await getUser(userId)) || userId; //real - //const fullname = (await generateStudent()).fullName; //fake - yAxisData.push(fullName); - } - //const fullname = await getUser(userId) || userId; //real name - //const fullname = (await generateStudent()).fullName; //populate with generated names - //yAxisData.push(fullname); - } - - this.series = { - name: "Lab Activity For All Users", - type: "heatmap", - data: allSeriesData || [], - selectedMode: "single", - top: "5%", - label: { - show: true - } - }; - - this.yAxisData = yAxisData; - this.renderChart(container); - } - - async populateAndRenderSingleUserData(session: Session, allLabs: Lo[]) { - const container = this.getChartContainer(); - if (!container) return; - - const userId = session.user.user_metadata.full_name ?? session.user.user_metadata.user_name; - this.yAxisData = [userId]; - - const seriesData: number[][] = await this.populatePerUserSeriesData(this.course, allLabs, session.user.user_metadata.user_name); - this.series = { - top: "5%", - name: "Lab Activity", - type: "heatmap", - data: seriesData || [], - selectedMode: "single", - label: { - show: true - } - }; - - this.renderChart(container); - } - - renderChart(container: HTMLElement) { - this.chartInstance = echarts.init(container); - const option: HeatMapChartConfig = heatmap(this.categories, this.yAxisData, this.series, bgPatternImg, "Lab Time: Per Student (click a cell to sort)"); - this.chartInstance.setOption(option); - this.chartInstance.resize(); - } - - prepareCombinedLabData(userIds: string[]) { - const labActivities = new Map(); - const labs = filterByType(this.course.los, "lab"); - const steps = filterByType(this.course.los, "step"); - - const allLabSteps = [...labs, ...steps]; - - allLabSteps?.forEach((step) => { - if (step.learningRecords) { - const title = step.parentLo?.type === "lab" ? step.parentLo?.title : step.title; - if (!labActivities.has(title)) { - labActivities.set(title, []); - } - - step.learningRecords.forEach((lab, key) => { - if (userIds.includes(key)) { - // Push the activity to the corresponding title in labActivities - labActivities.get(title).push({ - timeActive: lab.timeActive, - nickname: key - }); - } - }); - } - }); - - const heatmapData = Array.from(labActivities).map(([title, activities]) => { - activities.sort((a: { timeActive: number }, b: { timeActive: number }) => a.timeActive - b.timeActive); - const addedCount = activities.reduce((acc: number, curr: { timeActive: number }) => acc + curr.timeActive, 0); - - const lowData = activities[0]; - const highData = activities[activities.length - 1]; - return { - value: Math.round(addedCount / 2), - title: title, - lowValue: lowData?.timeActive || 0, - highValue: highData?.timeActive || 0, - lowNickname: lowData?.nickname || "No Interaction", - highNickname: highData?.nickname || "No Interaction" - }; - }); - - return heatmapData; - } - - async sortHeatMapValues() { - if (this.chartInstance !== null) { - this.chartInstance.off("click"); - this.chartInstance.on("click", async (params: { componentType: string; seriesType: string; value: any[] }) => { - if (params.componentType === "series" && params.seriesType === "heatmap") { - const colIndex = params.value[0]; // Column index of the clicked cell - // Extract the data for the clicked column - let columnData = this.series.data.filter((item: any[]) => item[0] === colIndex); - // Sort the column data by the value (timeActive) in ascending order - columnData.sort((a: number[], b: number[]) => a[2] - b[2]); - // Reorder yAxisData based on sorted column data - const sortedUserIndices = columnData.map((item: any[]) => item[1]); - const sortedYAxisData = sortedUserIndices.map((index: string | number) => this.yAxisData[index]); - // Reconstruct the series data with sorted y-axis order - let newData = this.series.data.map((item: any[]) => { - const newIndex = sortedUserIndices.indexOf(item[1]); - return [item[0], newIndex, item[2]]; - }); - // Update the y-axis data and series data - this.yAxisData = sortedYAxisData; - this.series.data = newData; - // Refresh the chart instance - this.chartInstance.setOption({ - yAxis: { - data: this.yAxisData - }, - series: [ - { - data: this.series.data - } - ] - }); - } - }); - } - } - - renderCombinedLabChart(container: HTMLElement, labData: any[], chartTitle: string) { - if (!labData || labData.length === 0) return; - - const chart = echarts.init(container); - - labData.sort((a, b) => a.title.localeCompare(b.title)); - - const heatmapData = labData.map((item, index) => [index, 0, item.value]); - const titles = labData.map((item) => item.title); - - // Ensure heatmapData and titles are not empty - const maxHeatmapValue = heatmapData.length > 0 ? Math.max(...heatmapData.map((item) => item[2])) : 0; - - const option = { - title: { - top: "5%", - left: "center", - text: chartTitle - }, - tooltip: { - position: "bottom", - formatter: function (params: { dataIndex: any }) { - const dataIndex = params.dataIndex; - const dataItem = labData[dataIndex]; - let tipHtml = dataItem.title + "
"; - tipHtml += "Min: " + dataItem.lowValue + " (" + dataItem.lowNickname + ")
"; - tipHtml += "Max: " + dataItem.highValue + " (" + dataItem.highNickname + ")"; - return tipHtml; - } - }, - backgroundColor: { - image: bgPatternImg, - repeat: "repeat" - }, - grid: { - height: "20%", - top: "15%" - }, - xAxis: { - type: "category", - data: titles, - splitArea: { - show: true - }, - axisLabel: { - interval: 1, - fontSize: 15 - }, - axisPointer: { - type: "shadow" - }, - position: "bottom" - }, - yAxis: { - type: "category", - data: [""], // Single category axis - axisLabel: { - interval: 0, - fontSize: 15 - } - }, - visualMap: { - min: 0, - max: maxHeatmapValue, // Ensure this handles empty data gracefully - calculable: true, - orient: "horizontal", - left: "center", - bottom: "5%" - }, - series: [ - { - name: "Value", - type: "heatmap", - data: heatmapData, - label: { - show: true - }, - emphasis: { - itemStyle: { - shadowBlur: 10, - shadowColor: "rgba(0, 0, 0, 0.5)" - } - } - } - ] - }; - - // Set the option to the chart - chart.setOption(option); - this.sortHeatMapValues(); - } -} diff --git a/src/lib/ui/time/supabase/analytics/lab-pie.ts b/src/lib/ui/time/supabase/analytics/lab-pie.ts deleted file mode 100644 index 39f5a9881..000000000 --- a/src/lib/ui/time/supabase/analytics/lab-pie.ts +++ /dev/null @@ -1,151 +0,0 @@ -import * as echarts from "echarts/core"; -import { TooltipComponent, LegendComponent, GridComponent } from "echarts/components"; -import { CanvasRenderer } from "echarts/renderers"; -import { PieChart, BarChart } from "echarts/charts"; -import { LabelLayout } from "echarts/features"; -import type { Course, Lo } from "$lib/services/models/lo-types"; -import { backgroundPattern, textureBackground } from "../charts/tutors-charts-background-url"; -import type { Session } from "@supabase/supabase-js"; -import { filterByType } from "$lib/services/models/lo-utils"; -import { piechart } from "../charts/piechart"; -import type { DrilledDownData } from "$lib/services/types/supabase-metrics"; - -echarts.use([TooltipComponent, LegendComponent, PieChart, BarChart, GridComponent, CanvasRenderer, LabelLayout]); - -const bgTexture = textureBackground; -const bgPatternSrc = backgroundPattern; - -const piePatternImg = new Image(); -piePatternImg.src = bgTexture; -const bgPatternImg = new Image(); -bgPatternImg.src = bgPatternSrc; - -export class LabPieChart { - myChart: any; - labs: string[]; - course: Course; - session: Session; - totalTimesMap: Map; - labTitleTimesMap: Map; - - constructor(course: Course, session: Session) { - this.myChart = null; - this.labs = []; - this.course = course; - this.session = session; - this.labTitleTimesMap = new Map(); - this.totalTimesMap = new Map(); - } - - singleUserPieClick() { - if (this.myChart !== null) { - // Remove any existing click listeners to prevent multiple handlers - this.myChart.off("click"); - this.myChart.on("click", (params: { seriesName: string; name: string }) => { - if (params.seriesName === "Inner Pie") { - const outerPieData: DrilledDownData[] = []; // Reset outerPieData array - - // Find the corresponding data for the clicked inner pie slice - this.totalTimesMap.forEach((steps, topicTitle) => { - if (topicTitle === params.name) { - steps.forEach((step) => { - if (step.value !== 0) { - outerPieData.push({ value: step.value, name: step.name, type: step.type }); - } - }); - } - }); - this.populateOuterPieData(outerPieData); - } - }); - } - } - - populateOuterPieData(outerPieData: DrilledDownData[]) { - // Update the data for the outer pie chart - const element = document.getElementById("chart"); - if (element) { - const chartInstance = echarts.getInstanceByDom(element); - if (chartInstance) { - chartInstance.setOption({ - series: [ - { - name: "Outer Pie", - data: outerPieData - } - ] - }); - } - } - } - - renderChart() { - if (!this.myChart) { - // Create a new chart instance if it doesn't exist - this.myChart = echarts.init(document.getElementById("chart")); - } else { - // Clear the previous chart to prevent aggregation issues - this.myChart.clear(); - } - - this.labTitleTimesMap.clear(); - this.totalTimesMap.clear(); - - let labs = filterByType(this.course.los, "lab"); - let steps = filterByType(this.course.los, "step"); - - const allLabSteps = [...labs, ...steps]; - - const updateMaps = (lo: Lo, timeActive: number) => { - let topicTitle = lo.type === "lab" ? lo.title : lo.parentLo!.title; - let loTitle = lo.title; - - // Add timeActive to the total time for the topic - if (this.labTitleTimesMap.has(topicTitle)) { - this.labTitleTimesMap.set(topicTitle, this.labTitleTimesMap.get(topicTitle)! + timeActive); - } else { - this.labTitleTimesMap.set(topicTitle, timeActive); - } - - if (!this.totalTimesMap.has(topicTitle)) { - this.totalTimesMap.set(topicTitle, []); - } - - const existingEntries = this.totalTimesMap.get(topicTitle)!; - const existingEntry = existingEntries.find((entry) => entry.name === loTitle); - - if (existingEntry) { - existingEntry.value += timeActive; - } else { - existingEntries.push({ value: timeActive, name: loTitle, type: lo.type }); - } - }; - - allLabSteps.forEach((lo) => { - const timeActive = lo.learningRecords?.get(this.session.user.user_metadata.user_name)?.timeActive || 0; - updateMaps(lo, timeActive); - }); - - const singleUserInnerData = Array.from(this.labTitleTimesMap.entries()).map(([title, timeActive]) => ({ - name: title, - value: timeActive - })); - - const singleUserOuterData: DrilledDownData[] = []; - this.totalTimesMap.forEach((steps, topicTitle) => { - steps.forEach((step) => { - if (step.type !== undefined) { - singleUserOuterData.push({ - name: step.name, - value: step.value, - type: step.type - }); - } - }); - }); - - const option = piechart(bgPatternImg, [], singleUserInnerData, singleUserOuterData); - this.myChart.setOption(option); - this.singleUserPieClick(); - } -} diff --git a/src/lib/ui/time/supabase/analytics/piechart/base-pie-chart.ts b/src/lib/ui/time/supabase/analytics/piechart/base-pie-chart.ts index 091df3fa9..3e6fa4e58 100644 --- a/src/lib/ui/time/supabase/analytics/piechart/base-pie-chart.ts +++ b/src/lib/ui/time/supabase/analytics/piechart/base-pie-chart.ts @@ -56,7 +56,7 @@ export class BasePieChart { if (title === params.name) { steps.forEach((step) => { if (step.value !== 0) { - outerPieData.push({ value: step.value, name: step.name, type: step.type }); + outerPieData.push({ value: Math.round(step.value / 2), name: step.name, type: step.type }); } }); } @@ -90,6 +90,7 @@ export class BasePieChart { const loTitle = lo.title; // Add timeActive to the total time for the title if (this.titleTimesMap.has(title)) { + // casting as unknow increses type safety and then casts as T which is number in this case this.titleTimesMap.set(title, ((this.titleTimesMap.get(title)! as number) + timeActive) as unknown as T); } else { this.titleTimesMap.set(title, timeActive as unknown as T); diff --git a/src/lib/ui/time/supabase/analytics/piechart/lab-pie-chart.ts b/src/lib/ui/time/supabase/analytics/piechart/lab-pie-chart.ts index 31a40ae41..021005df1 100644 --- a/src/lib/ui/time/supabase/analytics/piechart/lab-pie-chart.ts +++ b/src/lib/ui/time/supabase/analytics/piechart/lab-pie-chart.ts @@ -29,7 +29,7 @@ export class LabPieChart extends BasePieChart { } renderChart() { - super.renderChart(); // Initialize and set up click handlers + super.renderChart(); // Initialise and set up click handlers this.titleTimesMap.clear(); this.totalTimesMap.clear(); @@ -46,7 +46,7 @@ export class LabPieChart extends BasePieChart { const singleUserInnerData = Array.from(this.titleTimesMap.entries()).map(([title, timeActive]) => ({ name: title, - value: timeActive + value: Math.round(timeActive / 2) })); const singleUserOuterData: DrilledDownData[] = []; @@ -55,7 +55,7 @@ export class LabPieChart extends BasePieChart { if (step.type !== undefined) { singleUserOuterData.push({ name: step.name, - value: step.value, + value: Math.round(step.value / 2), type: step.type }); } diff --git a/src/lib/ui/time/supabase/analytics/piechart/topic-pie-chart.ts b/src/lib/ui/time/supabase/analytics/piechart/topic-pie-chart.ts index debdc1c8c..b3db0d506 100644 --- a/src/lib/ui/time/supabase/analytics/piechart/topic-pie-chart.ts +++ b/src/lib/ui/time/supabase/analytics/piechart/topic-pie-chart.ts @@ -11,7 +11,6 @@ import { BasePieChart } from "./base-pie-chart"; import { piechart } from "../../charts/piechart"; import { getCompositeValues, getSimpleTypesValues } from "$lib/services/utils/supabase-utils"; - echarts.use([TooltipComponent, LegendComponent, PieChart, BarChart, GridComponent, CanvasRenderer, LabelLayout]); const bgTexture = textureBackground; @@ -41,6 +40,7 @@ export class TopicPieChart extends BasePieChart { if (existing) { existing.value += value; } else { + value = Math.round(value / 2); outerPieData.push({ value, name: key }); } }); @@ -56,7 +56,7 @@ export class TopicPieChart extends BasePieChart { const allTypes = [...allComposites, ...allSimpleTypes]; allTypes.forEach((lo) => { - if (this.userIds && this.userIds.length > 0) { + if (this.multipleUsers) { this.userIds.forEach((userId) => { const timeActive = lo.learningRecords?.get(userId)?.timeActive || 0; this.updateMaps(lo, timeActive, (lo) => @@ -74,7 +74,7 @@ export class TopicPieChart extends BasePieChart { if (this.multipleUsers === false) { const singleUserInnerData = Array.from(this.titleTimesMap.entries()).map(([title, timeActive]) => ({ name: title, - value: timeActive + value: Math.round(timeActive / 2) })); const option = piechart(bgPatternImg, [], singleUserInnerData, []); diff --git a/src/lib/ui/time/supabase/analytics/topic-heat-map.ts b/src/lib/ui/time/supabase/analytics/topic-heat-map.ts deleted file mode 100644 index d6e4fd945..000000000 --- a/src/lib/ui/time/supabase/analytics/topic-heat-map.ts +++ /dev/null @@ -1,348 +0,0 @@ -import * as echarts from "echarts/core"; -import { TooltipComponent, GridComponent, VisualMapComponent } from "echarts/components"; -import { HeatmapChart } from "echarts/charts"; -import { CanvasRenderer } from "echarts/renderers"; -import { backgroundPattern } from "../charts/tutors-charts-background-url"; -import { heatmap } from "../charts/heatmap-chart"; -import type { Course, Lo } from "$lib/services/models/lo-types"; -import type { Session } from "@supabase/supabase-js"; -import type { HeatMapSeriesData } from "$lib/services/types/supabase-metrics"; -import { getCompositeValues, getSimpleTypesValues } from "$lib/services/utils/supabase-utils"; -import { generateStudent } from "../../../../../routes/(time)/simulate/generateStudent"; - -echarts.use([TooltipComponent, GridComponent, VisualMapComponent, HeatmapChart, CanvasRenderer]); - -const bgPatternImg = new Image(); -bgPatternImg.src = backgroundPattern; - -export class TopicHeatMapChart { - chartRendered: boolean = false; - chartInstances: Map; - course: Course; - categories: Set; - yAxisData: string[]; - series: HeatMapSeriesData; - topics: string[]; - session: Session; - userIds: string[]; - chart: any; - chartInstance: any; - userNamesUseridsMap: Map; - - constructor(course: Course, session: Session, userIds: string[], userNamesUseridsMap: Map) { - this.chartRendered = false; - this.chartInstances = new Map(); - this.topics = course.los.map((topic) => topic.title.trim()); - this.course = course; - this.categories = new Set(); - this.yAxisData = []; - this.series = { - top: "", - name: "", - data: [], - type: "heatmap", - selectedMode: "single", - label: { - show: true - } - }; - this.session = session; - this.userIds = userIds; - this.chart = null; - this.chartInstance = null; - this.userNamesUseridsMap = userNamesUseridsMap; - } - - populateUsersData() { - this.populateTopicTitles(this.course.los); - this.populateAndRenderUsersData(this.course.los, this.userIds); - } - - populateSingleUserData() { - this.populateTopicTitles(this.course.los); - this.populateAndRenderSingleUserData(); - } - - populateTopicTitles(allTopics: Lo[]) { - const topicTitles = allTopics.map((topic) => topic.title.trim()); - this.categories = new Set(topicTitles); - } - - getChartContainer() { - const container = document.getElementById("heatmap-container"); - if (container) { - container.style.width = "100%"; - container.style.height = "100%"; - } - return container; - } - - async prepareTopicData(userId: string, index: number = 0) { - const allComposites = getCompositeValues(this.course.los); - const allSimpleTypes = getSimpleTypesValues(this.course.los); - const allTypes = [...allComposites, ...allSimpleTypes]; - - const totalTimesMap = new Map(); - - allTypes.forEach((lo) => { - let title: string = ""; - if (lo.parentTopic?.type === "topic") { - title = lo.parentTopic?.title; - } else if (lo.parentLo?.parentTopic?.type === "topic") { - title = lo.parentLo?.parentTopic?.title; - } else { - title = lo.title; - } - const timeActive = lo.learningRecords?.get(userId)?.timeActive || 0; - - if (totalTimesMap.has(title)) { - totalTimesMap.set(title, totalTimesMap.get(title)! + timeActive); - } else { - totalTimesMap.set(title, timeActive); - } - }); - - const topicTitles = this.course.los.map((topic) => topic.title.trim()); - - const seriesData: number[][] = Array.from(totalTimesMap.entries()).map(([title, timeActive]) => { - return [topicTitles.indexOf(title.trim()), index, Math.round(timeActive / 2)]; - }); - - seriesData.sort((a, b) => b[2] - a[2]); - - //const userFullName = await getUser(userId) || userId; - return seriesData; - } - - async populateAndRenderSingleUserData() { - const container = this.getChartContainer(); - if (!container) return; - const userId = this.session.user.user_metadata.full_name ?? this.session.user.user_metadata.user_name; - - this.yAxisData = [userId]; - - const seriesData: number[][] = await this.prepareTopicData(this.session.user.user_metadata.user_name); - - this.series = { - name: "Topic Activity", - type: "heatmap", - data: seriesData || [], - selectedMode: "single", - top: "5%", - label: { - show: true - } - }; - - this.renderChart(container); - } - - async populateAndRenderUsersData(topics: Lo[], usersIds: string[]) { - const container = this.getChartContainer(); - if (!container) return; - - let allSeriesData: number[][] = []; - let yAxisData: string[] = []; - const topicTitles = topics.map((topic: { title: string }) => topic.title.trim()); - this.categories = new Set(topicTitles); - - for (const [index, userId] of usersIds.entries()) { - let seriesData: number[][] = await this.prepareTopicData(userId, index); - allSeriesData = allSeriesData.concat(seriesData); - - if (!yAxisData.includes(userId)) { - const fullName = this.userNamesUseridsMap.get(userId) || userId; - - //const fullname = (await getUser(userId)) || userId; //real - //const fullname = (await generateStudent()).fullName; //fake - yAxisData.push(fullName); - } - } - - allSeriesData.sort((a, b) => b[2] - a[2]); - - this.series = { - name: "Topic Activity For All Users", - type: "heatmap", - top: "5%", - data: allSeriesData || [], - selectedMode: "single", - label: { - show: true - } - }; - - this.yAxisData = yAxisData; - this.renderChart(container); - } - - renderChart(container: HTMLElement | null | undefined) { - this.chartInstance = echarts.init(container); - const option = heatmap(this.categories, this.yAxisData, this.series, bgPatternImg, "Topic Time: Per Student (click a cell to sort)"); - this.chartInstance.setOption(option); - } - - prepareCombinedTopicData(userIds: string[]) { - const topicActivities = new Map(); - const allComposites = getCompositeValues(this.course.los); - const allSimpleTypes = getSimpleTypesValues(this.course.los); - const allTypes = [...allComposites, ...allSimpleTypes]; - - allTypes.forEach((lo) => { - let title: string = ""; - if (lo.parentTopic?.type === "topic") { - title = lo.parentTopic?.title; - } else if (lo.parentLo?.parentTopic?.type === "topic") { - title = lo.parentLo?.parentTopic?.title; - } else { - title = lo.title; - } - - if (!topicActivities.has(title)) { - topicActivities.set(title, []); - } - - lo.learningRecords?.forEach((topic, userId) => { - if (userIds.includes(userId)) { - topicActivities.get(title).push({ - timeActive: topic.timeActive, - nickname: userId - }); - } - }); - }); - - const heatmapData = Array.from(topicActivities.entries()).map(([title, activities]) => { - activities.sort((a: { timeActive: number }, b: { timeActive: number }) => a.timeActive - b.timeActive); - - const addedCount = activities.reduce((acc: number, curr: { timeActive: any }) => acc + curr.timeActive, 0); - - const lowData = activities[0]; - const highData = activities[activities.length - 1]; - - return { - value: addedCount, - title: title, - lowValue: lowData?.timeActive || 0, - highValue: highData?.timeActive || 0, - lowNickname: lowData?.nickname || "No Interaction", - highNickname: highData?.nickname || "No Interaction" - }; - }); - - return heatmapData; - } - - async sortHeatMapValues() { - if (this.chartInstance !== null) { - this.chartInstance.off("click"); - this.chartInstance.on("click", async (params: { componentType: string; seriesType: string; value: any[] }) => { - if (params.componentType === "series" && params.seriesType === "heatmap") { - const colIndex = params.value[0]; // Column index of the clicked cell - // Extract the data for the clicked column - let columnData = this.series.data.filter((item: any[]) => item[0] === colIndex); - // Sort the column data by the value (timeActive) in ascending order - columnData.sort((a: number[], b: number[]) => a[2] - b[2]); - // Reorder yAxisData based on sorted column data - const sortedUserIndices = columnData.map((item: any[]) => item[1]); - const sortedYAxisData = sortedUserIndices.map((index: string | number) => this.yAxisData[index]); - // Reconstruct the series data with sorted y-axis order - let newData = this.series.data.map((item: any[]) => { - const newIndex = sortedUserIndices.indexOf(item[1]); - return [item[0], newIndex, item[2]]; - }); - // Update the y-axis data and series data - this.yAxisData = sortedYAxisData; - this.series.data = newData; - // Refresh the chart instance - this.chartInstance.setOption({ - yAxis: { - data: this.yAxisData - }, - series: [ - { - data: this.series.data - } - ] - }); - } - }); - } - } - - renderCombinedTopicChart(container: HTMLElement, heatmapActivities: any[], chartTitle: string) { - this.chart = echarts.init(container); - - const heatmapData = heatmapActivities.map((item, index) => [index, 0, Math.round(item.value / 2)]); - const titles = heatmapActivities.map((item) => item.title); - - const option = { - title: { - top: "15%", - left: "center", - text: chartTitle - }, - tooltip: { - position: "bottom", - formatter: function (params: { dataIndex: number }) { - const dataIndex = params.dataIndex; - const dataItem = heatmapActivities[dataIndex]; - if (dataItem) { - let tipHtml = dataItem.title + "
"; - tipHtml += "Min: " + dataItem.lowValue + " (" + dataItem.lowNickname + ")
"; - tipHtml += "Max: " + dataItem.highValue + " (" + dataItem.highNickname + ")"; - return tipHtml; - } - return ""; - } - }, - backgroundColor: { - image: bgPatternImg, - repeat: "repeat" - }, - grid: { - height: "30%", - top: "30%" - }, - xAxis: { - type: "category", - data: titles - }, - yAxis: { - type: "category", - data: [""] - }, - axisLabel: { - interval: 0, - fontSize: 15 - }, - visualMap: { - min: 0, - max: Math.max(...heatmapActivities.map((item) => item.value)), - calculable: true, - orient: "horizontal", - left: "center", - bottom: "15%" - }, - series: [ - { - name: "Value", - type: "heatmap", - data: heatmapData, - label: { - show: true - }, - emphasis: { - itemStyle: { - shadowBlur: 10, - shadowColor: "rgba(0, 0, 0, 0.5)" - } - } - } - ] - }; - - this.chart.setOption(option); - this.sortHeatMapValues(); - } -} diff --git a/src/lib/ui/time/supabase/analytics/topic-pie.ts b/src/lib/ui/time/supabase/analytics/topic-pie.ts deleted file mode 100644 index 16e223464..000000000 --- a/src/lib/ui/time/supabase/analytics/topic-pie.ts +++ /dev/null @@ -1,194 +0,0 @@ -import * as echarts from "echarts/core"; -import { backgroundPattern } from "../charts/tutors-charts-background-url"; -import { piechart } from "../charts/piechart"; -import type { Course, Lo } from "$lib/services/models/lo-types"; -import type { Session } from "@supabase/supabase-js"; -import { getCompositeValues, getSimpleTypesValues } from "$lib/services/utils/supabase-utils"; -import { PieChart } from "echarts/charts"; -import { TitleComponent, TooltipComponent, LegendComponent } from "echarts/components"; -import { CanvasRenderer } from "echarts/renderers"; -import type { DrilledDownData } from "$lib/services/types/supabase-metrics"; - -echarts.use([TitleComponent, TooltipComponent, LegendComponent, PieChart, CanvasRenderer]); - -const bgPatternSrc = backgroundPattern; - -const bgPatternImg = new Image(); -bgPatternImg.src = bgPatternSrc; - -export class TopicPieChart { - myChart: any; - topics: string[]; - course: Course; - session: Session; - topicTitleTimesMap = new Map(); - totalTimesMap: Map; - userIds: string[]; - multipleUsers: boolean; - - constructor(course: Course, session: Session, userIds: string[], multipleUsers: boolean) { - this.myChart = null; - this.topics = []; - this.course = course; - this.session = session; - this.userIds = userIds; - this.topicTitleTimesMap = new Map(); - this.totalTimesMap = new Map(); - this.multipleUsers = multipleUsers; - } - - singleUserPieClick() { - if (this.myChart !== null) { - // Listen to click event on the inner pie chart - this.myChart.on("click", (params: { seriesName: string; name: string }) => { - if (params.seriesName === "Inner Pie") { - let outerPieData: DrilledDownData[] = []; // Reset outerPieData array - - // Find the corresponding data for the clicked inner pie slice - this.totalTimesMap.forEach((steps, topicTitle) => { - if (topicTitle === params.name) { - steps.forEach((step) => { - if (step.value !== 0) { - outerPieData.push({ value: step.value, name: step.name, type: step.type }); - } - }); - } - }); - this.populateOuterPieData(outerPieData); - } - }); - } - } - - populateOuterPieData(outerPieData: DrilledDownData[]) { - const element = document.getElementById("chart"); - if (element) { - // Update the data for the outer pie chart - const chartInstance = echarts.getInstanceByDom(element); - if (chartInstance) { - chartInstance.setOption({ - series: [ - { - name: "Outer Pie", - data: outerPieData.filter((topic) => topic.value > 0) || [{ value: 0, name: "No data" }] - } - ] - }); - } - } - } - - multipleUsersInnerPieClick() { - if (this.myChart !== null) { - // Remove any existing click listeners to prevent multiple handlers - this.myChart.off("click"); - this.myChart.on("click", (params: { seriesName: string; name: string }) => { - if (params.seriesName === "Inner Pie") { - const outerPieData: DrilledDownData[] = []; - - this.totalTimesMap.forEach((steps, topicTitle) => { - if (topicTitle === params.name) { - steps.forEach((step) => { - const existing = outerPieData.find((data) => data.name === step.name); - if (existing) { - existing.value += step.value; - } else { - outerPieData.push({ value: step.value, name: step.name, type: step.type }); - } - }); - } - }); - this.populateOuterPieData(outerPieData); - } - }); - } - } - - getOuterPieDataForMultipleUsers(): { value: number; name: string }[] { - const outerPieData: { value: number; name: string }[] = []; - this.topicTitleTimesMap.forEach((value, key) => { - const existing = outerPieData.find((data) => data.name === key); - if (existing) { - existing.value += value; - } else { - outerPieData.push({ value, name: key }); - } - }); - - return outerPieData; - } - - renderChart(userIds = null as string[] | null) { - if (!this.myChart) { - // Create a new chart instance if it doesn't exist - this.myChart = echarts.init(document.getElementById("chart")); - } else { - // Clear the previous chart to prevent aggregation issues - this.myChart.clear(); - } - - const allComposites = getCompositeValues(this.course.los); - const allSimpleTypes = getSimpleTypesValues(this.course.los); - const allTypes = [...allComposites, ...allSimpleTypes]; - - const updateMaps = (lo: Lo, userName: string, timeActive: number) => { - let topicTitle = ""; - let loTitle = lo.title; - if (lo.parentTopic?.type === "topic") { - topicTitle = lo.parentTopic?.title; - } else if (lo.parentLo?.parentTopic?.type === "topic") { - topicTitle = lo.parentLo?.parentTopic?.title; - } else { - topicTitle = lo.title; - } - - // Add timeActive to the total time for the topic - if (this.topicTitleTimesMap.has(topicTitle)) { - this.topicTitleTimesMap.set(topicTitle, this.topicTitleTimesMap.get(topicTitle)! + timeActive); - } else { - this.topicTitleTimesMap.set(topicTitle, timeActive); - } - - if (!this.totalTimesMap.has(topicTitle)) { - this.totalTimesMap.set(topicTitle, []); - } - - const existingEntries = this.totalTimesMap.get(topicTitle)!; - const existingEntry = existingEntries.find((entry) => entry.name === loTitle); - - if (existingEntry) { - existingEntry.value += timeActive; - } else { - existingEntries.push({ value: timeActive, name: loTitle, type: lo.type }); - } - }; - - allTypes.forEach((lo) => { - if (userIds && userIds.length > 0) { - userIds.forEach((userId) => { - const timeActive = lo.learningRecords?.get(userId)?.timeActive || 0; - updateMaps(lo, userId, timeActive); - }); - } else { - const timeActive = lo.learningRecords?.get(this.session.user.user_metadata.user_name)?.timeActive || 0; - updateMaps(lo, this.session.user.user_metadata.user_name, timeActive); - } - }); - - if (this.multipleUsers === false) { - const singleUserInnerData = Array.from(this.topicTitleTimesMap.entries()).map(([title, timeActive]) => ({ - name: title, - value: timeActive - })); - - const option = piechart(bgPatternImg, [], singleUserInnerData, []); - this.myChart.setOption(option); - this.singleUserPieClick(); - } else { - const allUsersTopicActivity = this.getOuterPieDataForMultipleUsers(); - const option = piechart(bgPatternImg, allUsersTopicActivity, [], []); - this.myChart.setOption(option); - this.multipleUsersInnerPieClick(); - } - } -} diff --git a/src/lib/ui/time/supabase/charts/heatmap-chart.ts b/src/lib/ui/time/supabase/charts/heatmap-chart.ts index 0a29ddaf4..6856a475e 100644 --- a/src/lib/ui/time/supabase/charts/heatmap-chart.ts +++ b/src/lib/ui/time/supabase/charts/heatmap-chart.ts @@ -14,7 +14,7 @@ export function heatmap(categories: Set, yAxisData: string[], series: He }; if (series?.data) { - if (series.name === "Lab Activity For All Users" || series.name === "Topic Activity For All Users") { + if (series.name === "lab activity for all users" || series.name === "topic activity for all users") { gridConfig = { left: "30%", right: "30%", @@ -97,3 +97,74 @@ export function heatmap(categories: Set, yAxisData: string[], series: He series: seriesArray }; } + +export function renderCombinedChart(heatmapActivities: any[], bgPatternImg: HTMLImageElement, chartTitle: string) { + const heatmapData = heatmapActivities.map((item, index) => [index, 0, Math.round(item.value / 2)]); + const titles = heatmapActivities.map((item) => item.title); + + return { + title: { + top: "15%", + left: "center", + text: chartTitle + }, + tooltip: { + position: "bottom", + formatter: function (params: { dataIndex: number }) { + const dataIndex = params.dataIndex; + const dataItem = heatmapActivities[dataIndex]; + if (dataItem) { + let tipHtml = dataItem.title + "
"; + tipHtml += "Min: " + dataItem.lowValue + " (" + dataItem.lowNickname + ")
"; + tipHtml += "Max: " + dataItem.highValue + " (" + dataItem.highNickname + ")"; + return tipHtml; + } + return ""; + } + }, + backgroundColor: { + image: bgPatternImg, + repeat: "repeat" + }, + grid: { + height: "30%", + top: "30%" + }, + xAxis: { + type: "category", + data: titles + }, + yAxis: { + type: "category", + data: [""] + }, + axisLabel: { + interval: 0, + fontSize: 15 + }, + visualMap: { + min: 0, + max: Math.max(...heatmapActivities.map((item) => item.value / 2)), + calculable: true, + orient: "horizontal", + left: "center", + bottom: "15%" + }, + series: [ + { + name: "Value", + type: "heatmap", + data: heatmapData, + label: { + show: true + }, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, 0.5)" + } + } + } + ] + }; +} diff --git a/src/lib/ui/time/supabase/views/Chart.svelte b/src/lib/ui/time/supabase/views/Chart.svelte index 5eca01168..561b50872 100644 --- a/src/lib/ui/time/supabase/views/Chart.svelte +++ b/src/lib/ui/time/supabase/views/Chart.svelte @@ -18,6 +18,9 @@ chartInstance = new LabPieChart(course, session); } else if (chartType === 'TopicPieChart') { chartInstance = new TopicPieChart(course, session, userIds, multipleUsers); + }else{ + //this would be type never the narraowest type + throw new Error(`Invalid chart type: ${chartType}`); } renderChart(); }); diff --git a/src/lib/ui/time/supabase/views/HeatMapChart.svelte b/src/lib/ui/time/supabase/views/HeatMapChart.svelte new file mode 100644 index 000000000..c90816079 --- /dev/null +++ b/src/lib/ui/time/supabase/views/HeatMapChart.svelte @@ -0,0 +1,58 @@ + + +
+ {#if multipleUsers} +
+
+ {:else} +
+ {/if} +
diff --git a/src/lib/ui/time/supabase/views/InstructorLabView.svelte b/src/lib/ui/time/supabase/views/InstructorLabView.svelte index fbb4a0d9d..d1761034a 100644 --- a/src/lib/ui/time/supabase/views/InstructorLabView.svelte +++ b/src/lib/ui/time/supabase/views/InstructorLabView.svelte @@ -1,47 +1,13 @@ -
-
-
-
+ diff --git a/src/lib/ui/time/supabase/views/InstructorTopicView.svelte b/src/lib/ui/time/supabase/views/InstructorTopicView.svelte index aa5d7efaa..1ce0d3f3e 100644 --- a/src/lib/ui/time/supabase/views/InstructorTopicView.svelte +++ b/src/lib/ui/time/supabase/views/InstructorTopicView.svelte @@ -1,55 +1,13 @@ -
-
-
-
+ diff --git a/src/lib/ui/time/supabase/views/LabView.svelte b/src/lib/ui/time/supabase/views/LabView.svelte index 8f259aa76..9ba6873f7 100644 --- a/src/lib/ui/time/supabase/views/LabView.svelte +++ b/src/lib/ui/time/supabase/views/LabView.svelte @@ -1,45 +1,13 @@ -
-
-
+ diff --git a/src/lib/ui/time/supabase/views/TopicView.svelte b/src/lib/ui/time/supabase/views/TopicView.svelte index ed9425144..eac538bd8 100644 --- a/src/lib/ui/time/supabase/views/TopicView.svelte +++ b/src/lib/ui/time/supabase/views/TopicView.svelte @@ -1,39 +1,13 @@ - -
-
-
- - \ No newline at end of file + import type { Course } from "$lib/services/models/lo-types"; + import TopicHeatMapChart from "./HeatMapChart.svelte"; + import type { Session } from "@supabase/supabase-js"; + + export let course: Course; + export let session: Session; + export let userIds: string[]; + export let userNamesUseridsMap: Map; + export let multipleUsers: boolean = false; + + + diff --git a/src/routes/(time)/next-time/[courseid]/+page.svelte b/src/routes/(time)/next-time/[courseid]/+page.svelte index a15e0d0c9..326fc9e0d 100644 --- a/src/routes/(time)/next-time/[courseid]/+page.svelte +++ b/src/routes/(time)/next-time/[courseid]/+page.svelte @@ -57,90 +57,163 @@ } - -
- - - + + +
- {#if $storeTab === 'Topics'} + {#if $storeTab === "Topics"} {#if instructorMode} - - - + + + {:else} - - + + {/if} {/if} - {#if $storeTab === 'Labs'} + {#if $storeTab === "Labs"} {#if instructorMode} - - + + {:else} - - + + {/if} {/if}
- {#if $storeTab === 'Calendar'} + {#if $storeTab === "Calendar"} {#if instructorMode} - + {:else} {/if} - {:else if $storeTab === 'Topics'} + {:else if $storeTab === "Topics"} {#if instructorMode} - {#if $storeSubTab === 'InstructorTopicView'} + {#if $storeSubTab === "InstructorTopicView"} - {:else if $storeSubTab === 'InstructorTopicViewPieChart'} + {:else if $storeSubTab === "InstructorTopicViewPieChart"} - {:else if $storeSubTab === 'InstructorTopicViewBoxPlot'} - + {:else if $storeSubTab === "InstructorTopicViewBoxPlot"} + {:else} {/if} + {:else if $storeSubTab === "TopicView"} + + {:else if $storeSubTab === "TopicViewPieChart"} + {:else} - {#if $storeSubTab === 'TopicView'} - - {:else if $storeSubTab === 'TopicViewPieChart'} - - {:else} - - {/if} + {/if} - {:else if $storeTab === 'Labs'} + {:else if $storeTab === "Labs"} {#if instructorMode} - {#if $storeSubTab === 'InstructorLabView'} - - {:else if $storeSubTab === 'InstructorLabViewBoxPlot'} - + {#if $storeSubTab === "InstructorLabView"} + + {:else if $storeSubTab === "InstructorLabViewBoxPlot"} + {:else} - + {/if} + {:else if $storeSubTab === "LabView"} + + {:else if $storeSubTab === "LabViewPieChart"} + {:else} - {#if $storeSubTab === 'LabView'} - - {:else if $storeSubTab === 'LabViewPieChart'} - - {:else} - - {/if} + {/if} {/if}
+ +