diff --git a/src/lib/services/models/course-utils.ts b/src/lib/services/models/course-utils.ts index 3a3478f11..0d0c11219 100644 --- a/src/lib/services/models/course-utils.ts +++ b/src/lib/services/models/course-utils.ts @@ -2,6 +2,25 @@ import { addIcon } from "$lib/ui/themes/styles/icon-lib"; import type { Composite, Course, IconNav, Lo, LoType, Topic } from "./lo-types"; import { filterByType, setShowHide } from "./lo-utils"; +function threadToc(lo: Lo, course: Course) { + if (lo.type == "topic") { + const topic = lo as Topic; + topic.toc = []; + topic.toc.push(...topic.panels.panelVideos, ...topic.panels.panelTalks, ...topic.panels.panelNotes, ...topic.units.units, ...topic.units.standardLos, ...topic.units.sides); + + topic.toc.forEach((lo) => { + lo.parentLo = course; + lo.parentTopic = topic; + if (lo.type === "unit" || lo.type === "side") { + const composite = lo as Composite; + composite.los.forEach((subLo) => { + subLo.parentTopic = topic; + }); + } + }); + } +} + export function createToc(course: Course) { course.los.forEach((lo) => { if (lo.type == "topic") { diff --git a/src/lib/services/models/lo-tree.ts b/src/lib/services/models/lo-tree.ts index 42411fee8..9ad885ee9 100644 --- a/src/lib/services/models/lo-tree.ts +++ b/src/lib/services/models/lo-tree.ts @@ -1,6 +1,6 @@ import { isCompositeLo, type Course, type Lo, type Composite, type LoType, type Topic } from "./lo-types"; import { convertLoToHtml } from "./markdown-utils"; -import { allVideoLos, crumbs, flattenLos, loadIcon, getPanels, getUnits, injectCourseUrl, removeUnknownLos } from "./lo-utils"; +import { allVideoLos, crumbs, flattenLos, loadIcon, getPanels, getUnits, injectCourseUrl, removeUnknownLos, filterByType } from "./lo-utils"; import { createCompanions, createToc, createWalls, initCalendar, loadPropertyFlags } from "./course-utils"; export function decorateCourseTree(course: Course, courseId: string = "", courseUrl = "") { @@ -26,12 +26,14 @@ export function decorateCourseTree(course: Course, courseId: string = "", course const videoLos = allVideoLos(allLos); videoLos.forEach((lo) => course.loIndex.set(lo.video, lo)); course.topicIndex = new Map<string, Topic>(); - course.los.forEach((lo) => course.topicIndex.set(lo.route, lo as Topic)); + //course.los.forEach((lo) => course.topicIndex.set(lo.route, lo as Topic)); + const topicLos = filterByType(allLos, "topic"); + topicLos.forEach((lo) => course.topicIndex.set(lo.route, lo as Topic)); loadPropertyFlags(course); createCompanions(course); createWalls(course); - createToc(course); + // createToc(course); initCalendar(course); } @@ -51,6 +53,17 @@ export function decorateLoTree(course: Course, lo: Lo) { const compositeLo = lo as Composite; compositeLo.panels = getPanels(compositeLo.los); compositeLo.units = getUnits(compositeLo.los); + + compositeLo.toc = []; + compositeLo.toc.push( + ...compositeLo?.panels?.panelVideos, + ...compositeLo?.panels?.panelTalks, + ...compositeLo?.panels?.panelNotes, + ...compositeLo?.units?.units, + ...compositeLo?.units?.standardLos, + ...compositeLo?.units?.sides + ); + for (const childLo of compositeLo.los) { childLo.parentLo = lo; if (compositeLo.los) { diff --git a/src/lib/services/models/lo-types.ts b/src/lib/services/models/lo-types.ts index 5f030e77e..b10bdfa3e 100644 --- a/src/lib/services/models/lo-types.ts +++ b/src/lib/services/models/lo-types.ts @@ -155,13 +155,13 @@ export type Units = { }; export type Composite = Lo & { + toc: Lo[]; los: Lo[]; // child los panels: Panels; // child panel los - paneltalks, panelvideos, panelnotes. units: Units; // child units, including side units }; export type Topic = Composite & { - toc: Lo[]; type: "topic"; }; diff --git a/src/lib/services/types/supabase-metrics.ts b/src/lib/services/types/supabase-metrics.ts index 0b8b53fa3..55dd6b9c8 100644 --- a/src/lib/services/types/supabase-metrics.ts +++ b/src/lib/services/types/supabase-metrics.ts @@ -1,51 +1,206 @@ +export interface CalendarMap { + date: string; + timeActive: number; +} + +export interface LearningObject { + route: string; + loTitle: string; + parentLoTitle: string | undefined; + date: Date; + pageLoads: number; + timeActive: number; + nickname: string; +} + export interface LearningInteraction { - id?: Date; - loid?: string, - courseid: string, - studentid: string, - date: Date; - pageloads: number; - timeactive: number; + id?: Date; + loid?: string; + courseid: string; + studentid: string; + date: Date; + pageloads: number; + timeactive: number; +} + +export interface GridConfig { + left: string | number; + right: string | number; + bottom: string | number; + top: string | number; + width: string | number; + height: string | number; + containLabel: boolean; +} + +export type ChartType = { + type: + | "line" + | "bar" + | "pie" + | "scatter" + | "effectScatter" + | "radar" + | "tree" + | "treemap" + | "sunburst" + | "boxplot" + | "candlestick" + | "heatmap" + | "parallel" + | "lines" + | "graph" + | "sankey" + | "funnel" + | "gauge" + | "pictorialBar" + | "themeRiver" + | "calendar" + | "map" + | "custom"; }; -export interface HeatMapSeriesData { - name: string; - type: string; - top: string; - data: number[][]; - label: { show: boolean; }; +export interface BackgroundColor { + image: HTMLImageElement; + repeat: "repeat"; +} + +export type HeatmapShowBoolean = { + show: boolean; }; -export interface LearningObject { - route: string; - loTitle: string; - parentLoTitle: string | undefined; - date: Date; - pageLoads: number; - timeActive: number; - nickname: string; +export interface AxisLabel { + interval: number; + fontSize: number; + margin?: number; // Adjust margin to control spacing + padding?: number[]; +} + +export interface AxisTick { + alignWithLabel: boolean; +} + +export interface axisPointer { + type: "line" | "shadow" | "none"; +} + +export interface XAxis { + type: "value" | "category"; + data: string[] | number[]; + boundaryGap?: number[]; + nameLocation?: "start" | "middle" | "center" | "end"; + splitArea?: HeatmapShowBoolean; + axisLabel?: AxisLabel; + axisTick?: AxisTick; + axisPointer?: axisPointer; + position?: "top" | "bottom"; +} + +export interface YAxis { + type: "value" | "category"; + data: string[]; + splitArea?: HeatmapShowBoolean; + axisLabel?: AxisLabel; +} + +export interface Color { + image: HTMLImageElement; + repeat: string; +} + +export interface ItemStyle { + color: Color; + borderWidth: number; + borderColor: string; +} + +export interface Tooltip { + position: "top" | "bottom" | "left" | "right"; + trigger?: "item"; + formatter?: (param: string | number) => string; + //formatter: string; //"{a} <br/>{b}: {c} mins" +} + +export interface Series { + type: ChartType; + data: number[]; + itemStyle: ItemStyle; +} + +export interface BoxplotChartConfig { + backgroundColor: BackgroundColor; + xAxis: XAxis; + yAxis: YAxis; + series: Series[]; + tooltip: Tooltip; +} + +export type ChartTitle = { + top: string; + left: string; + text: string; }; -export interface CalendarMap { - date: string; - timeActive: number; +export interface VisualMap { + min: number; + max: number; + calculable: boolean; + orient: "horizontal" | "vertical"; + left: number | string | "center" | "left" | "right"; + align: "auto" | "left" | "right" | "center"; + bottom: number | string | "center" | "top" | "bottom"; +} + +export interface HeatMapChartConfig { + title: ChartTitle; + tooltip: Tooltip; + backgroundColor: BackgroundColor; + grid: GridConfig; + xAxis: XAxis; + yAxis: YAxis; + visualMap: VisualMap; + series: HeatMapSeriesData[]; +} + +export interface BoxplotTooltip { + trigger: "item"; + formatter: (param: string | number) => string; + //formatter: string; //"{a} <br/>{b}: {c} mins" +} + +export interface HeatmapTooltip { + position: "top" | "bottom" | "left" | "right"; + trigger: "item"; + formatter: string; //"{a} <br/>{b}: {c} mins" +} + +export type NameTypeData = { + name: string; + type?: string; }; -export interface LabStepData { - aggregatedTimeActive: number; - title: string; - loType: string; +export interface HeatMapSeriesData { + top: string | number; + name: string; + type: "heatmap"; + selectedMode: "single" | "multiple"; + data: number[][]; + label: HeatmapShowBoolean; } -export interface BoxplotData { - value: [number, number, number, number, number]; - title: string; - lowNickname: string; - highNickname: string; +// export type HeatMapSeriesData = NameTypeData & { +// data: number[][]; +// top: string; +// selectedMode?: string; +// label: { show: boolean }; +// }; + +export type BoxplotData = NameTypeData & { + value: [number, number, number, number, number]; + lowNickname: string; + highNickname: string; }; -export type OuterPieData = { - value: number; - name: string; - type: string; - }; \ No newline at end of file +export type DrilledDownData = NameTypeData & { + value: number; +}; diff --git a/src/lib/services/utils/supabase-utils.ts b/src/lib/services/utils/supabase-utils.ts index f7b3ee8ce..b29a0af88 100644 --- a/src/lib/services/utils/supabase-utils.ts +++ b/src/lib/services/utils/supabase-utils.ts @@ -73,6 +73,7 @@ export async function getDurationTotal(key: string, table: string, id: string): } export async function insertOrUpdateCalendar(studentId: string, courseId: string) { + if(!studentId || !courseId) return; const durationPromise = getCalendarDuration(formatDate(new Date()), studentId, courseId); const countPromise = getCalendarCount(formatDate(new Date()), studentId, courseId); const [timeActive, pageLoads] = await Promise.all([durationPromise, countPromise]); @@ -205,12 +206,12 @@ export const updateCalendarDuration = async (id: string, studentId: string, cour export async function storeStudentCourseLearningObjectInSupabase(course: Course, loid: string, lo: Lo, userDetails: User) { // const loTitle = getLoTitle(params) - if (userDetails?.user_metadata.full_name === "Anon") return; + if (userDetails?.user_metadata?.full_name === "Anon") return; // await insertOrUpdateCourse(course); // await addOrUpdateStudent(userDetails); // await addOrUpdateLo(loid, lo, lo.title); - await handleInteractionData(course.courseId, userDetails.user_metadata.user_name, loid, lo); - await insertOrUpdateCalendar(userDetails.user_metadata.user_name, course.courseId); + await handleInteractionData(course.courseId, userDetails?.user_metadata?.user_name, loid, lo); + await insertOrUpdateCalendar(userDetails?.user_metadata?.user_name, course.courseId); } export async function handleInteractionData(courseId: string, studentId: string, loId: string, lo: Lo) { @@ -290,25 +291,27 @@ export function getSimpleTypesValues(los: Lo[]) { return [...notes, ...archives, ...webs, ...githubs, ...panelnotes, ...paneltalks, ...panelVideos, ...talks, ...books, ...labs, ...steps]; } -export async function getUser(username: string) { - return fetch(`https://api.github.com/users/${username}`) - .then((response) => response.json()) - .then((response) => { - return response.name; - }); +export async function getUserNames(usernames: string[]): Promise<Map<string, string>> { + const userMap = new Map<string, string>(); + for (const username of usernames) { + const response = await fetch(`https://api.github.com/users/${username}`); + const user = await response.json(); + if (user.name) { + userMap.set(username, user.name); + } + } + return userMap; } -export async function getGithubAvatarUrl(username: string) { - const url = `https://api.github.com/users/${username}`; - try { +export async function getGithubAvatarUrl(usernames: string[]): Promise<Map<string, string>> { + const imageMap = new Map<string, string>(); + for (const username of usernames) { + const url = `https://api.github.com/users/${username}`; const response = await fetch(url); - if (!response.ok) { - throw new Error(`User not found: ${response.status}`); - } const data = await response.json(); - return data.avatar_url; - } catch (error) { - console.error("Error fetching the avatar URL:", error); - return null; + if (data.avatar_url) { + imageMap.set(username, data.avatar_url); + } } + return imageMap; } diff --git a/src/lib/ui/learning-objects/layout/TopicContextPanel.svelte b/src/lib/ui/learning-objects/layout/LoContextPanel.svelte similarity index 76% rename from src/lib/ui/learning-objects/layout/TopicContextPanel.svelte rename to src/lib/ui/learning-objects/layout/LoContextPanel.svelte index 5447c987e..b9a71426b 100644 --- a/src/lib/ui/learning-objects/layout/TopicContextPanel.svelte +++ b/src/lib/ui/learning-objects/layout/LoContextPanel.svelte @@ -1,12 +1,11 @@ <script lang="ts"> - import type { Topic } from "$lib/services/models/lo-types"; import type { Lo } from "$lib/services/models/lo-types"; import Image from "../../themes/Image.svelte"; import { currentLo, layout } from "$lib/stores"; import { onDestroy } from "svelte"; - import TopicContext from "../structure/TopicContext.svelte"; + import LoContext from "../structure/LoContext.svelte"; - export let topic: Topic; + export let loContext: Lo; let imageHeight = ""; let headingText = ""; @@ -29,11 +28,11 @@ </script> <div class="card {cardWidths} px-4 py-2"> - <h3 class="px-4 py-2 text-center {headingText}">{topic?.title}</h3> + <h3 class="px-4 py-2 text-center {headingText}">{loContext?.title}</h3> <div class="card-body"> <figure class="flex justify-center p-2"> <Image {lo} /> </figure> - <TopicContext {topic} /> + <LoContext lo={loContext} /> </div> </div> diff --git a/src/lib/ui/learning-objects/structure/Context.svelte b/src/lib/ui/learning-objects/structure/Context.svelte index d93fd5fd2..b3083bcee 100644 --- a/src/lib/ui/learning-objects/structure/Context.svelte +++ b/src/lib/ui/learning-objects/structure/Context.svelte @@ -1,7 +1,13 @@ <script lang="ts"> import type { Lo } from "$lib/services/models/lo-types"; - import TopicContextPanel from "../layout/TopicContextPanel.svelte"; + import LoContextPanel from "../layout/LoContextPanel.svelte"; export let lo: Lo; + let loContext = lo; + if (loContext) { + while (loContext.type !== "topic" && loContext.type !== "course") { + loContext = loContext.parentLo!; + } + } </script> <div class="flex w-11/12 mx-auto"> @@ -10,9 +16,9 @@ <slot /> {/key} </div> - {#if lo.parentTopic} + {#if loContext} <div class="hidden md:block"> - <TopicContextPanel topic={lo.parentTopic} /> + <LoContextPanel {loContext} /> </div> {/if} </div> diff --git a/src/lib/ui/learning-objects/structure/CourseContext.svelte b/src/lib/ui/learning-objects/structure/CourseContext.svelte index 42bf38e48..50b0897c6 100644 --- a/src/lib/ui/learning-objects/structure/CourseContext.svelte +++ b/src/lib/ui/learning-objects/structure/CourseContext.svelte @@ -1,6 +1,6 @@ <script lang="ts"> - import type { Course } from "$lib/services/models/lo-types"; - import TopicContext from "./TopicContext.svelte"; + import { isCompositeLo, type Course } from "$lib/services/models/lo-types"; + import LoContext from "./LoContext.svelte"; import { Accordion, AccordionItem } from "@skeletonlabs/skeleton"; export let course: Course; @@ -8,11 +8,11 @@ <Accordion regionPanel="space-y-0.5"> {#each course.los as lo} - {#if lo.type === "topic" && !lo.hide} + {#if !lo.hide} <AccordionItem> <svelte:fragment slot="summary">{lo.title}</svelte:fragment> <svelte:fragment slot="content"> - <TopicContext topic={lo} /> + <LoContext {lo} /> </svelte:fragment> </AccordionItem> {/if} diff --git a/src/lib/ui/learning-objects/structure/LoContext.svelte b/src/lib/ui/learning-objects/structure/LoContext.svelte new file mode 100644 index 000000000..1ddc84590 --- /dev/null +++ b/src/lib/ui/learning-objects/structure/LoContext.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import type { Lo } from "$lib/services/models/lo-types"; + import LoReference from "./LoReference.svelte"; + + export let lo: Lo; + if (lo?.toc) { + lo?.toc.forEach((lo) => { + if (lo?.route.endsWith("/")) { + lo.route = lo?.route.slice(0, -1); + } + }); + } +</script> + +{#each lo?.toc as lo} + <LoReference {lo} /> + {#if lo.toc} + {#each lo?.toc as lo} + <LoReference {lo} indent={4} /> + {#if lo.toc} + {#each lo?.toc as lo} + <LoReference {lo} indent={8} /> + {#if lo.toc} + {#each lo?.toc as lo} + <LoReference {lo} indent={12} /> + {#if lo.toc} + {#each lo?.toc as lo} + <LoReference {lo} indent={16} /> + {/each} + {/if} + {/each} + {/if} + {/each} + {/if} + {/each} + {/if} +{/each} diff --git a/src/lib/ui/learning-objects/structure/LoReference.svelte b/src/lib/ui/learning-objects/structure/LoReference.svelte new file mode 100644 index 000000000..6b86d17e0 --- /dev/null +++ b/src/lib/ui/learning-objects/structure/LoReference.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import type { Lo } from "$lib/services/models/lo-types"; + import Icon from "$lib/ui/themes/icons/Icon.svelte"; + + export let lo: Lo; + export let indent = 0; +</script> + +<a href={lo?.route} class="flex py-1 pl-{indent}"> + <Icon type={lo.type} /> + <span class="ml-2 mb-1"> {@html lo.title} </span> + {#if lo.video && lo.type != "panelvideo"} + <a class="flex pl-4" href={lo.video}> + <Icon type="video" /> + </a> + {/if} +</a> diff --git a/src/lib/ui/learning-objects/structure/TopicContext.svelte b/src/lib/ui/learning-objects/structure/TopicContext.svelte deleted file mode 100644 index 5de40bb23..000000000 --- a/src/lib/ui/learning-objects/structure/TopicContext.svelte +++ /dev/null @@ -1,37 +0,0 @@ -<script lang="ts"> - import type { Topic } from "$lib/services/models/lo-types"; - import { currentCourse } from "$lib/stores"; - import Icon from "$lib/ui/themes/icons/Icon.svelte"; - - export let topic: Topic; -</script> - -{#each topic?.toc as lo} - <a href={lo.type === "unit" ? lo?.parentTopic?.route : lo.type === "side" ? lo?.parentTopic?.route : lo?.route} class="flex py-1"> - <Icon type={lo.type} /> - <span class="ml-2 mb-1"> {@html lo.title} </span> - {#if lo.video && lo.type != "panelvideo"} - <a class="flex pl-1" href={lo.video}> - <Icon type="video" /> - </a> - {/if} - </a> - {#if lo.type != "lab"} - {#if lo.los} - {#each lo.los as lo} - <div class="flex py-1"> - <a class="inline-flex pl-6" href={lo.route}> - <Icon type={lo.type} /> <span class="pl-2"> {lo.title} </span> - </a> - {#if lo.video && lo.type != "panelvideo"} - {#if !$currentCourse.areVideosHidden} - <a class="inline-flex pl-2" href={lo.video}> - <Icon type="video" /> - </a> - {/if} - {/if} - </div> - {/each} - {/if} - {/if} -{/each} diff --git a/src/lib/ui/themes/icons/Breadcrumbs.svelte b/src/lib/ui/themes/icons/Breadcrumbs.svelte index ab9109800..3a6f33f23 100644 --- a/src/lib/ui/themes/icons/Breadcrumbs.svelte +++ b/src/lib/ui/themes/icons/Breadcrumbs.svelte @@ -1,19 +1,7 @@ <script lang="ts"> - import type { Lo } from "$lib/services/models/lo-types"; import { currentCourse, currentLo } from "$lib/stores"; import Icon from "./Icon.svelte"; - let truncated = [true, true, true, true, true, true, true]; - let unitId = ""; - - function getUnitId(type: string, id: string) { - if (type == "unit" || type == "side") { - unitId = id; - } else { - unitId = ""; - } - return unitId; - } function truncate(input: string) { if (input.length > 16) { @@ -29,12 +17,25 @@ return input; } - let breadCrumbs: Lo[]; + interface Crumb { + route: string; + type: string; + title: string; + } + let breadCrumbs: Crumb[] = []; + currentLo.subscribe((lo) => { - breadCrumbs = lo.breadCrumbs; - if (breadCrumbs.length > 1) { + breadCrumbs = []; + lo.breadCrumbs?.forEach((lo) => { + let route = lo.route; + if (route.endsWith("/")) { + route = route.slice(0, -1); + } + breadCrumbs.push({ route: route, type: lo.type, title: lo.title }); + }); + if (breadCrumbs.length > 2) { if (breadCrumbs[1].type === "unit" || breadCrumbs[1].type === "side") { - breadCrumbs.splice(1, 1); + breadCrumbs[1].route = breadCrumbs[1].route.replace("topic", "course"); } } }); @@ -57,7 +58,7 @@ <li class="crumb-separator" aria-hidden>›</li> {/if} <li class="crumb"> - <a href="{lo.route}{getUnitId(lo.type, lo.id)}" class="!space-x-[-1rem] lg:!space-x-0 inline-flex !text-black dark:!text-white"> + <a href={lo.route} class="!space-x-[-1rem] lg:!space-x-0 inline-flex !text-black dark:!text-white"> <span><Icon type={lo.type} tip={`Go to ${lo.title}`} /></span> <!-- svelte-ignore a11y-no-static-element-interactions --> <span diff --git a/src/lib/ui/time/supabase/analytics/calendar.ts b/src/lib/ui/time/supabase/analytics/calendar.ts index 735b44937..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, getUser } 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]); @@ -23,17 +23,21 @@ bgPatternImg.src = backgroundPattern; export class CalendarChart { chartRendered: boolean; - myChart: any; - chartDom: any; + myChart: any; + chartDom: any; myCharts: { [key: string]: any }; medianCalendarRendered: boolean; + userNamesUseridsMap: Map<string, string>; + userAvatarsUseridsMap: Map<string, string>; - constructor() { + constructor(userAvatarsUseridsMap: Map<string, string>, userNamesUseridsMap: Map<string, string>) { this.chartRendered = false; this.myChart = null; this.chartDom = null; this.myCharts = {}; this.medianCalendarRendered = false; + this.userAvatarsUseridsMap = userAvatarsUseridsMap; + this.userNamesUseridsMap = userNamesUseridsMap; } createChartContainer(containerId: string) { @@ -113,7 +117,7 @@ export class CalendarChart { //this.clickMonth(); } - async renderCombinedChart(course: Course, calendarMap: Map<string, number>, userId: string) { + async renderCombinedChart(calendarMap: Map<string, number>, userId: string) { const chartContainer = this.getChartContainer(userId); if (!chartContainer) { @@ -122,13 +126,21 @@ export class CalendarChart { } const chart = echarts.init(chartContainer); - - //const student = await generateStudent(); //generate fake student - const avatarUrl = await getGithubAvatarUrl(userId) - const fullName = await getUser(userId) + const fullName = this.userNamesUseridsMap.get(userId) || userId; + const avatarUrl = this.userAvatarsUseridsMap.get(userId) || ""; const option = calendarCombined(userId, calendarMap, bgPatternImg, currentRange, avatarUrl, fullName); chart.setOption(option, true); + + //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); + // const option = calendarCombined(userId, calendarMap, bgPatternImg, currentRange, avatarUrl, fullName); + + // chart.setOption(option, true); } // New method to render the additional calendar for median timeactive values 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<T> { + chartRendered = false; + chartInstances: Map<echarts.ECharts, echarts.ECharts>; + course: Course; + session: Session; + userIds: string[]; + userNamesUseridsMap: Map<string, string>; + chartInstance: echarts.ECharts | null = null; + categories: Set<string> = 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<string, string>, 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<number[][]> { + const totalTimesMap = new Map<string, number>(); + 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<number> { + labs: Lo[]; + + constructor(course: any, session: Session, userIds: string[], userNamesUseridsMap: Map<string, string>, 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<number> { + topics: Lo[]; + + constructor(course: Course, session: Session, userIds: string[], userNamesUseridsMap: Map<string, string>, 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-box-plot.ts b/src/lib/ui/time/supabase/analytics/lab-box-plot.ts index 4d6cbe865..f03a5ca78 100644 --- a/src/lib/ui/time/supabase/analytics/lab-box-plot.ts +++ b/src/lib/ui/time/supabase/analytics/lab-box-plot.ts @@ -7,9 +7,9 @@ import { backgroundPattern } from "../charts/tutors-charts-background-url"; import { boxplot, combinedBoxplotChart } from "../charts/boxplot-chart"; import type { Course } from "$lib/services/models/lo-types"; import { filterByType } from "$lib/services/models/lo-utils"; -import type { BoxplotData } from "$lib/services/types/supabase-metrics"; +import type { BoxplotChartConfig, BoxplotData } from "$lib/services/types/supabase-metrics"; import { generateStudent } from "../../../../../routes/(time)/simulate/generateStudent"; -import { getUser } from "$lib/services/utils/supabase-utils"; +// import { getUser } from "$lib/services/utils/supabase-utils"; echarts.use([TitleComponent, TooltipComponent, GridComponent, BoxplotChart, CanvasRenderer]); @@ -19,10 +19,12 @@ bgPatternImg.src = backgroundPattern; export class LabBoxPlotChart { course: Course; userIds: string[]; + userNamesUseridsMap: Map<string, string>; - constructor(course: Course, userIds: string[]) { + constructor(course: Course, userIds: string[], userNamesUseridsMap: Map<string, string>) { this.course = course; this.userIds = userIds; + this.userNamesUseridsMap = userNamesUseridsMap; } async getName(): Promise<string> { @@ -54,7 +56,8 @@ export class LabBoxPlotChart { const userActivitiesPromises = Array.from(userActivities.entries()).map(async ([userId, activities]) => { if (activities.length > 0) { //const fullname = await this.getName(); //generate fake name - const fullname = (await getUser(userId)) || userId; + //const fullname = (await getUser(userId)) || userId; + const fullname = this.userNamesUseridsMap.get(userId) || userId; activities.sort((a, b) => a - b); const min = d3.min(activities) ?? 0; const q1 = d3.quantile(activities, 0.25) ?? 0; @@ -87,7 +90,8 @@ export class LabBoxPlotChart { for (const [userId, labRecord] of lab.learningRecords || []) { if (this.userIds?.includes(userId) && labRecord.timeActive != null) { //const nickname = await this.getName(); //generate fake names - const nickname = (await getUser(userId)) || userId; + //const nickname = (await getUser(userId)) || userId; + const nickname = this.userNamesUseridsMap.get(userId) || userId; labActivities.get(title)?.push({ timeActive: labRecord.timeActive, nickname: nickname @@ -125,7 +129,7 @@ export class LabBoxPlotChart { boxplotData.push({ value: [min, q1, median, q3, max], - title: title, + name: title, lowNickname: lowNickname, highNickname: highNickname }); @@ -139,7 +143,7 @@ export class LabBoxPlotChart { if (!container) return; const chart = echarts.init(container); - const option = boxplot(bgPatternImg, userNicknames, boxplotData, "Lab Activity per Student Boxplot"); + const option: BoxplotChartConfig = boxplot(bgPatternImg, userNicknames, boxplotData, "Lab Activity per Student Boxplot"); chart.setOption(option); } 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 69899fc9e..000000000 --- a/src/lib/ui/time/supabase/analytics/lab-heat-map.ts +++ /dev/null @@ -1,358 +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 } from "$lib/services/types/supabase-metrics"; -import { getUser } 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 LabHeatMapChart { - chartRendered: boolean = false; - chartInstances: Map<any, any>; - labs: Lo[] | undefined; - categories: Set<string>; - yAxisData: string[]; - course: Course; - session: Session; - series: HeatMapSeriesData[]; - los: Lo[]; - userIds: string[]; - chartInstance: any; - - constructor(course: Course, session: Session, userIds: string[]) { - 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 = []; - this.course = course; - this.session = session; - this.los = filterByType(this.course.los, "lab"); - this.chartInstance = null; - } - - 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) { - 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<string, number>(); - - // 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 = Array.from(totalTimesMap.entries()).map(([title, timeActive], stepIndex) => { - return [labTitles.indexOf(title.trim()), index, Math.round(timeActive / 2)]; - }); - - //const userFullName = await getUser(userId) || userId; - const userFullName = userId; - - return [ - { - name: "Lab Activity for " + userFullName, - type: "heatmap", - data: seriesData, - label: { - show: true - } - } - ]; - } - - async populateAndRenderUsersData(course: Course, allLabs: Lo[], userIds: string[]) { - const container = this.getChartContainer(); - if (!container) return; - - let allSeriesData: HeatMapSeriesData[] = []; - let yAxisData: string[] = []; // Array to store yAxis data - - const labTitles = allLabs.map((lab: { title: string }) => lab.title.trim()); - this.categories = new Set(labTitles); - - for (const [index, userId] of userIds.entries()) { - const seriesData = await this.populatePerUserSeriesData(course, allLabs, userId, index); - allSeriesData = allSeriesData.concat(seriesData[0].data); - - if (!yAxisData.includes(userId)) { - 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 || [], - 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 = await this.populatePerUserSeriesData(this.course, allLabs, session.user.user_metadata.user_name); - this.series = [ - { - top: "5%", - name: "Lab Activity", - type: "heatmap", - data: seriesData[0]?.data || [], - label: { - show: true - } - } - ]; - - this.renderChart(container); - } - - renderChart(container: HTMLElement) { - this.chartInstance = echarts.init(container); - const option = 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[0].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[0].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[0].data = newData; - // Refresh the chart instance - this.chartInstance.setOption({ - yAxis: { - data: this.yAxisData - }, - series: [ - { - data: this.series[0].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 + "<br />"; - tipHtml += "Min: " + dataItem.lowValue + " (" + dataItem.lowNickname + ")<br />"; - 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 f161972d4..000000000 --- a/src/lib/ui/time/supabase/analytics/lab-pie.ts +++ /dev/null @@ -1,149 +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 type { EChartsOption } from "echarts"; -import { piechart } from "../charts/piechart"; -import type { LabStepData, OuterPieData } from "$lib/services/types/supabase-metrics"; - -echarts.use([TooltipComponent, LegendComponent, PieChart, BarChart, GridComponent, CanvasRenderer, LabelLayout]); - -let option: EChartsOption; - -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<string, LabStepData[]>; - labTitleTimesMap: Map<string, number>; - - 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: OuterPieData[] = []; // 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.aggregatedTimeActive !== 0) { - outerPieData.push({ value: step.aggregatedTimeActive, name: step.title, type: step.loType }); - } - }); - } - }); - this.populateOuterPieData(outerPieData); - } - }); - } - } - - populateOuterPieData(outerPieData: OuterPieData[]) { - // Update the data for the outer pie chart - const chartInstance = echarts.getInstanceByDom(document.getElementById("chart")); - 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.title === loTitle); - - if (existingEntry) { - existingEntry.aggregatedTimeActive += timeActive; - } else { - existingEntries.push({ aggregatedTimeActive: timeActive, title: loTitle, loType: 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: OuterPieData[] = []; - this.totalTimesMap.forEach((steps, topicTitle) => { - steps.forEach((step) => { - singleUserOuterData.push({ - name: step.title, - value: step.aggregatedTimeActive, - type: step.loType - }); - }); - }); - - const option = piechart(bgPatternImg, this.course, [], 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 new file mode 100644 index 000000000..3e6fa4e58 --- /dev/null +++ b/src/lib/ui/time/supabase/analytics/piechart/base-pie-chart.ts @@ -0,0 +1,123 @@ +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 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 BasePieChart<T> { + myChart: any; + course: Course; + session: Session; + totalTimesMap: Map<string, DrilledDownData[]>; + titleTimesMap: Map<string, T>; + + constructor(course: Course, session: Session) { + this.myChart = null; + this.course = course; + this.session = session; + this.titleTimesMap = new Map(); + this.totalTimesMap = new Map(); + } + + initChart() { + 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(); + } + } + + handlePieClick() { + 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, title) => { + if (title === params.name) { + steps.forEach((step) => { + if (step.value !== 0) { + outerPieData.push({ value: Math.round(step.value / 2), 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 + } + ] + }); + } + } + } + + updateMaps(lo: Lo, timeActive: number, getTitle: (lo: Lo) => string) { + const title = getTitle(lo); + 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); + } + + if (!this.totalTimesMap.has(title)) { + this.totalTimesMap.set(title, []); + } + + const existingEntries = this.totalTimesMap.get(title)!; + const existingEntry = existingEntries.find((entry) => entry.name === loTitle); + + if (existingEntry) { + existingEntry.value += timeActive; + } else { + existingEntries.push({ value: timeActive, name: loTitle, type: lo.type }); + } + } + + renderChart() { + this.initChart(); + this.handlePieClick(); + } + + setOption(option: any) { + if (this.myChart) { + this.myChart.setOption(option); + } + } +} 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 new file mode 100644 index 000000000..021005df1 --- /dev/null +++ b/src/lib/ui/time/supabase/analytics/piechart/lab-pie-chart.ts @@ -0,0 +1,68 @@ +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 } 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"; +import { BasePieChart } from "./base-pie-chart"; +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 extends BasePieChart<number> { + labs: string[]; + + constructor(course: Course, session: Session) { + super(course, session); + this.labs = []; + } + + renderChart() { + super.renderChart(); // Initialise and set up click handlers + + this.titleTimesMap.clear(); + this.totalTimesMap.clear(); + + let labs = filterByType(this.course.los, "lab"); + let steps = filterByType(this.course.los, "step"); + + const allLabSteps = [...labs, ...steps]; + + allLabSteps.forEach((lo) => { + const timeActive = lo.learningRecords?.get(this.session.user.user_metadata.user_name)?.timeActive || 0; + this.updateMaps(lo, timeActive, (lo) => (lo.type === "lab" ? lo.title : lo.parentLo!.title)); + }); + + const singleUserInnerData = Array.from(this.titleTimesMap.entries()).map(([title, timeActive]) => ({ + name: title, + value: Math.round(timeActive / 2) + })); + + const singleUserOuterData: DrilledDownData[] = []; + this.totalTimesMap.forEach((steps, title) => { + steps.forEach((step) => { + if (step.type !== undefined) { + singleUserOuterData.push({ + name: step.name, + value: Math.round(step.value / 2), + type: step.type + }); + } + }); + }); + + const option = piechart(bgPatternImg, [], singleUserInnerData, singleUserOuterData); + super.setOption(option); + } +} 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 new file mode 100644 index 000000000..b3db0d506 --- /dev/null +++ b/src/lib/ui/time/supabase/analytics/piechart/topic-pie-chart.ts @@ -0,0 +1,88 @@ +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 type { DrilledDownData } from "$lib/services/types/supabase-metrics"; +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; +const bgPatternSrc = backgroundPattern; + +const piePatternImg = new Image(); +piePatternImg.src = bgTexture; +const bgPatternImg = new Image(); +bgPatternImg.src = bgPatternSrc; + +export class TopicPieChart extends BasePieChart<number> { + topics: string[]; + userIds: string[]; + multipleUsers: boolean; + + constructor(course: Course, session: Session, userIds: string[], multipleUsers: boolean) { + super(course, session); + this.topics = []; + this.userIds = userIds; + this.multipleUsers = multipleUsers; + } + + getOuterPieDataForMultipleUsers(): DrilledDownData[] { + const outerPieData: { value: number; name: string }[] = []; + this.titleTimesMap.forEach((value, key) => { + const existing = outerPieData.find((data) => data.name === key); + if (existing) { + existing.value += value; + } else { + value = Math.round(value / 2); + outerPieData.push({ value, name: key }); + } + }); + + return outerPieData; + } + + renderChart() { + super.renderChart(); // Initialize and set up click handlers + + const allComposites = getCompositeValues(this.course.los); + const allSimpleTypes = getSimpleTypesValues(this.course.los); + const allTypes = [...allComposites, ...allSimpleTypes]; + + allTypes.forEach((lo) => { + if (this.multipleUsers) { + this.userIds.forEach((userId) => { + const timeActive = lo.learningRecords?.get(userId)?.timeActive || 0; + this.updateMaps(lo, timeActive, (lo) => + lo.parentTopic?.type === "topic" ? lo.parentTopic.title : lo.parentLo?.parentTopic?.type === "topic" ? lo.parentLo?.parentTopic?.title : lo.title + ); + }); + } else { + const timeActive = lo.learningRecords?.get(this.session.user.user_metadata.user_name)?.timeActive || 0; + this.updateMaps(lo, timeActive, (lo) => + lo.parentTopic?.type === "topic" ? lo.parentTopic.title : lo.parentLo?.parentTopic?.type === "topic" ? lo.parentLo?.parentTopic?.title : lo.title + ); + } + }); + + if (this.multipleUsers === false) { + const singleUserInnerData = Array.from(this.titleTimesMap.entries()).map(([title, timeActive]) => ({ + name: title, + value: Math.round(timeActive / 2) + })); + + const option = piechart(bgPatternImg, [], singleUserInnerData, []); + super.setOption(option); + } else { + const allUsersTopicActivity = this.getOuterPieDataForMultipleUsers(); + const option = piechart(bgPatternImg, allUsersTopicActivity, [], []); + super.setOption(option); + } + } +} diff --git a/src/lib/ui/time/supabase/analytics/topic-box-plot.ts b/src/lib/ui/time/supabase/analytics/topic-box-plot.ts index 06a5d541b..3f03c0cb6 100644 --- a/src/lib/ui/time/supabase/analytics/topic-box-plot.ts +++ b/src/lib/ui/time/supabase/analytics/topic-box-plot.ts @@ -5,7 +5,7 @@ import { CanvasRenderer } from "echarts/renderers"; import { backgroundPattern } from "../charts/tutors-charts-background-url"; import { boxplot, combinedBoxplotChart } from "../charts/boxplot-chart"; import type { Course } from "$lib/services/models/lo-types"; -import { getCompositeValues, getSimpleTypesValues, getUser } from "$lib/services/utils/supabase-utils"; +import { getCompositeValues, getSimpleTypesValues } from "$lib/services/utils/supabase-utils"; import type { BoxplotData } from "$lib/services/types/supabase-metrics"; import * as d3 from "d3"; import { generateStudent } from "../../../../../routes/(time)/simulate/generateStudent"; @@ -35,10 +35,12 @@ function calculateBoxplotStats(values: number[]): [number, number, number, numbe export class TopicBoxPlotChart { course: Course; userIds: string[]; + userNamesUseridsMap: Map<string, string>; - constructor(course: Course, userIds: string[]) { + constructor(course: Course, userIds: string[], userNamesUseridsMap: Map<string, string>) { this.course = course; this.userIds = userIds; + this.userNamesUseridsMap = userNamesUseridsMap; } async getName(): Promise<string> { @@ -67,7 +69,9 @@ export class TopicBoxPlotChart { const userActivitiesPromises = Array.from(userActivities.entries()).map(async ([userId, activities]) => { if (activities.length > 0) { //const fullname = await this.getName(); //generate fakenames - const fullname = (await getUser(userId)) || userId; + //const fullname = (await getUser(userId)) || userId; + const fullname = this.userNamesUseridsMap.get(userId) || userId; + const [min, q1, median, q3, max] = calculateBoxplotStats(activities); boxplotData.push([min, q1, median, q3, max]); userNicknames.push(fullname); // replace with userId when changing back @@ -103,7 +107,9 @@ export class TopicBoxPlotChart { const recordsPromises = Array.from(lo.learningRecords.entries()).map(async ([userId, record]) => { if (this.userIds.includes(userId)) { //const nickname = await this.getName(); //generate a fake name - const nickname = (await getUser(userId)) || userId; + //const nickname = (await getUser(userId)) || userId; + const nickname = this.userNamesUseridsMap.get(userId) || userId; + topicActivities.get(title)!.push({ timeActive: record.timeActive, nickname: nickname // change to userId when changing back @@ -126,7 +132,7 @@ export class TopicBoxPlotChart { return { value: [min, q1, median, q3, max], - title: title, + name: title, lowNickname: lowNickname, highNickname: highNickname }; 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 8fd4c89b3..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, Topic } 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, getUser } 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<any, any>; - course: Course; - categories: Set<string>; - yAxisData: string[]; - series: any[]; - topics: string[]; - session: Session; - userIds: string[]; - chart: any; - chartInstance: any; - - constructor(course: Course, session: Session, userIds: string[]) { - 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 = []; - this.session = session; - this.userIds = userIds; - this.chart = null; - this.chartInstance = null; - } - - 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<string, number>(); - - 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()); - - let seriesData = 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; - const userFullName = userId; - return [ - { - name: "Topic Activity for " + userFullName, - type: "heatmap", - data: seriesData, - label: { - show: true - } - } - ]; - } - - 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 = await this.prepareTopicData(this.session.user.user_metadata.user_name); - - this.series = [ - { - name: "Topic Activity", - type: "heatmap", - data: seriesData[0]?.data || [], - 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: HeatMapSeriesData[] = []; - 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()) { - const seriesData = await this.prepareTopicData(userId, index); - allSeriesData = allSeriesData.concat(seriesData[0].data); - - if (!yAxisData.includes(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", - 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[0].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[0].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[0].data = newData; - // Refresh the chart instance - this.chartInstance.setOption({ - yAxis: { - data: this.yAxisData - }, - series: [ - { - data: this.series[0].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 + "<br />"; - tipHtml += "Min: " + dataItem.lowValue + " (" + dataItem.lowNickname + ")<br />"; - 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 32a245a0a..000000000 --- a/src/lib/ui/time/supabase/analytics/topic-pie.ts +++ /dev/null @@ -1,184 +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 { LabStepData, OuterPieData } 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<string, number>(); - totalTimesMap: Map<string, LabStepData[]>; - 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<string, number>(); - this.totalTimesMap = new Map<string, LabStepData[]>(); - 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: OuterPieData[] = []; // 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.aggregatedTimeActive !== 0) { - outerPieData.push({ value: step.aggregatedTimeActive, name: step.title, type: step.loType }); - } - }); - } - }); - this.populateOuterPieData(outerPieData); - } - }); - } - } - - populateOuterPieData(outerPieData: OuterPieData[]) { - // Update the data for the outer pie chart - const chartInstance = echarts.getInstanceByDom(document.getElementById("chart")); - if (chartInstance) { - chartInstance.setOption({ - series: [ - { - name: "Outer Pie", - data: outerPieData.filter((topic) => topic.value > 0) || [{}] - } - ] - }); - } - } - - multipleUsersInnerPieClick() { - this.myChart.on("click", (params: { seriesName: string; name: any }) => { - if (params.seriesName === "Inner Pie") { - const outerPieData: OuterPieData[] = []; - - this.totalTimesMap.forEach((steps, topicTitle) => { - if (topicTitle === params.name) { - steps.forEach((step) => { - const existing = outerPieData.find((data) => data.name === step.title); - if (existing) { - existing.value += step.aggregatedTimeActive; - } else { - outerPieData.push({ value: step.aggregatedTimeActive, name: step.title, type: step.loType }); - } - }); - } - }); - 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 === null) { - // If chart instance doesn't exist, create a new one - this.myChart = echarts.init(document.getElementById("chart")); - } - - 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.title === loTitle); - - if (existingEntry) { - existingEntry.aggregatedTimeActive += timeActive; - } else { - existingEntries.push({ aggregatedTimeActive: timeActive, title: loTitle, loType: 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, this.course, [], singleUserInnerData, []); - this.myChart.setOption(option); - this.singleUserPieClick(); - } else { - const allUsersTopicActivity = this.getOuterPieDataForMultipleUsers(); - const option = piechart(bgPatternImg, this.course, allUsersTopicActivity, [], []); - this.myChart.setOption(option); - this.multipleUsersInnerPieClick(); - } - } -} diff --git a/src/lib/ui/time/supabase/charts/barchart.ts b/src/lib/ui/time/supabase/charts/barchart.ts index 34418421a..2eaf1e171 100644 --- a/src/lib/ui/time/supabase/charts/barchart.ts +++ b/src/lib/ui/time/supabase/charts/barchart.ts @@ -1,6 +1,8 @@ +import type { BoxplotChartConfig, ChartType } from "$lib/services/types/supabase-metrics"; import type { EChartsOption } from "echarts"; -export function barchart(piePatternImg: HTMLImageElement, bgPatternImg: HTMLImageElement, chartData: UserMetric[]): EChartsOption { +export function barchart(piePatternImg: string, bgPatternImg: string, chartData: UserMetric[]): BoxplotChartConfig { + let type: ChartType = "bar"; // OK return { backgroundColor: { image: bgPatternImg, @@ -16,7 +18,7 @@ export function barchart(piePatternImg: HTMLImageElement, bgPatternImg: HTMLImag }, series: [ { - type: "bar", + type: type, data: chartData.map((user) => Math.round(user.duration / 2) || 0), itemStyle: { color: { diff --git a/src/lib/ui/time/supabase/charts/boxplot-chart.ts b/src/lib/ui/time/supabase/charts/boxplot-chart.ts index d7060ddc2..d418b605f 100644 --- a/src/lib/ui/time/supabase/charts/boxplot-chart.ts +++ b/src/lib/ui/time/supabase/charts/boxplot-chart.ts @@ -1,7 +1,7 @@ -import type { BoxplotData } from "$lib/services/types/supabase-metrics"; +import type { BoxplotData, BoxplotChartConfig } from "$lib/services/types/supabase-metrics"; import * as echarts from "echarts"; -export function boxplot(bgPatternImg: HTMLImageElement, userNicknames: string[], boxplotData: number[][], chartTitle: string): echarts.EChartsOption { +export function boxplot(bgPatternImg: HTMLImageElement, userNicknames: string[], boxplotData: number[][], chartTitle: string): BoxplotChartConfig { return { title: { text: chartTitle @@ -25,7 +25,8 @@ export function boxplot(bgPatternImg: HTMLImageElement, userNicknames: string[], data: userNicknames }, xAxis: { - type: "value" + type: "value", + boundaryGap: [0, 0.3] }, series: [ { @@ -54,7 +55,7 @@ export function combinedBoxplotChart(bgPatternImg: HTMLImageElement, boxplotData formatter: function (params) { const dataIndex = params.dataIndex; const dataItem = boxplotData[dataIndex]; - let tipHtml = `${dataItem.title}<br />`; + let tipHtml = `${dataItem.name}<br />`; tipHtml += `Min: ${dataItem.value[0]} (${dataItem.lowNickname})<br />`; tipHtml += `Q1: ${dataItem.value[1]}<br />`; tipHtml += `Median: ${dataItem.value[2]}<br />`; @@ -65,7 +66,7 @@ export function combinedBoxplotChart(bgPatternImg: HTMLImageElement, boxplotData }, xAxis: { type: "category", - data: boxplotData.map((item) => item.title), // topic titles + data: boxplotData.map((item) => item.name), // topic titles boundaryGap: true, nameGap: 30, splitArea: { @@ -91,7 +92,7 @@ export function combinedBoxplotChart(bgPatternImg: HTMLImageElement, boxplotData formatter: function (params) { const dataIndex = params.dataIndex; const dataItem = boxplotData[dataIndex]; - let tipHtml = `${dataItem.title}<br />`; + let tipHtml = `${dataItem.name}<br />`; tipHtml += `Min: ${dataItem.value[0]} (${dataItem.lowNickname})<br />`; tipHtml += `Q1: ${dataItem.value[1]}<br />`; tipHtml += `Median: ${dataItem.value[2]}<br />`; diff --git a/src/lib/ui/time/supabase/charts/heatmap-chart.ts b/src/lib/ui/time/supabase/charts/heatmap-chart.ts index 5ab8f39e0..724abb148 100644 --- a/src/lib/ui/time/supabase/charts/heatmap-chart.ts +++ b/src/lib/ui/time/supabase/charts/heatmap-chart.ts @@ -1,44 +1,40 @@ -import type { HeatMapSeriesData } from "$lib/services/types/supabase-metrics"; -import type { EChartsOption } from "echarts"; +import type { ChartType, GridConfig, HeatMapChartConfig, HeatMapSeriesData } from "$lib/services/types/supabase-metrics"; -export function heatmap(categories: Set<string>, yAxisData: string[], series: HeatMapSeriesData[], bgPatternImg: HTMLImageElement, chartTitleString: string): EChartsOption { +export function heatmap(categories: Set<string>, yAxisData: string[], series: HeatMapSeriesData, bgPatternImg: HTMLImageElement, chartTitleString: string): HeatMapChartConfig { let visualmapValue: number; - let gridConfig; + let seriesArray: HeatMapSeriesData[] = [series]; + let gridConfig: GridConfig = { + left: "30%", + right: "30%", + bottom: "15%", + top: "10%", + width: "40%", // Fixed width + height: "80%", // Fixed height + containLabel: false + }; - if (series[0]?.data) { - if (series[0].name === "Lab Activity For All Users" || series[0].name === "Topic Activity For All Users") { + if (series?.data) { + if (series.name === "lab activity for all users" || series.name === "topic activity for all users") { gridConfig = { - left: "30%", - right: "30%", - bottom: "15%", + left: "15%", + right: "10%", + bottom: "10%", top: "10%", - width: "40%", // Fixed width + width: "80%", // Fixed width height: "80%", // Fixed height containLabel: false // Prevent resizing based on labels }; } else { gridConfig = { - left: "20%", - right: "10%", + left: "15%", + right: "15%", bottom: "15%", top: "15%", - width: "60%", // Fixed width + width: "80%", // Fixed width height: "80px", // Fixed height - containLabel: false // Prevent resizing based on labels + containLabel: true // Prevent resizing based on labels }; } - - visualmapValue = series[0].data.length !== 0 ? Math.max(...series[0].data.map((item) => item[2])) : 0; - } else if (series?.data) { - gridConfig = { - left: "30%", - right: "30%", - bottom: "15%", - top: "15%", - width: "40%", // Fixed width - height: "40%", // Fixed height - containLabel: false // Prevent resizing based on labels - }; visualmapValue = series.data.length !== 0 ? Math.max(...series.data.map((item) => item[2])) : 0; } else { visualmapValue = 0; @@ -65,8 +61,8 @@ export function heatmap(categories: Set<string>, yAxisData: string[], series: He show: true }, axisLabel: { - interval: 1, - fontSize: 15, + interval: 3, + fontSize: 12, margin: 10 // Adjust margin to control spacing }, axisTick: { @@ -78,24 +74,97 @@ export function heatmap(categories: Set<string>, yAxisData: string[], series: He position: "bottom" }, yAxis: { - type: "category", - data: yAxisData[0] !== undefined ? yAxisData : "", - splitArea: { - show: true - }, + type: "category", + data: yAxisData[0] !== undefined ? yAxisData : [], + splitArea: { + show: true + }, axisLabel: { interval: 0, fontSize: 15, padding: [10, 0, 10, 0] // Increase space between rows } - }, + }, visualMap: { min: 0, max: visualmapValue, calculable: true, orient: "horizontal", - left: "center" + left: "center", + align: "auto", + bottom: 0 + }, + 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 }, - series: series + tooltip: { + position: "bottom", + formatter: function (params: { dataIndex: number }) { + const dataIndex = params.dataIndex; + const dataItem = heatmapActivities[dataIndex]; + if (dataItem) { + let tipHtml = dataItem.title + "<br />"; + tipHtml += "Min: " + dataItem.lowValue + " (" + dataItem.lowNickname + ")<br />"; + 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: 12 + }, + 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/charts/piechart.ts b/src/lib/ui/time/supabase/charts/piechart.ts index 2a5b9180a..43e6f151f 100644 --- a/src/lib/ui/time/supabase/charts/piechart.ts +++ b/src/lib/ui/time/supabase/charts/piechart.ts @@ -1,12 +1,7 @@ import type { Course } from "$lib/services/models/lo-types"; +import type { DrilledDownData } from "$lib/services/types/supabase-metrics"; -export function piechart( - bgPatternImg: HTMLImageElement, - course: Course, - allUsersTopicActivity: any[], - singleUserInnerData: { name: string; value: number }[], - singleUserOuterData: { value: number; name: string; type: string }[] -) { +export function piechart(bgPatternImg: HTMLImageElement, allUsersTopicActivity: any[], singleUserInnerData: DrilledDownData[], singleUserOuterData: DrilledDownData[]) { return { tooltip: { trigger: "item", diff --git a/src/lib/ui/time/supabase/views/CalendarView.svelte b/src/lib/ui/time/supabase/views/CalendarView.svelte index 3de1fab7f..8b0b71c2d 100644 --- a/src/lib/ui/time/supabase/views/CalendarView.svelte +++ b/src/lib/ui/time/supabase/views/CalendarView.svelte @@ -6,9 +6,11 @@ export let timeActiveMap: Map<string, Map<string, number>>; export let session: Session; export let medianTime: Map<string, number>; + export let userNamesUseridsMap: Map<string, string>; + export let userAvatarsUseridsMap: Map<string, string>; let calendarChart: CalendarChart | null; - calendarChart = new CalendarChart(); + calendarChart = new CalendarChart(userAvatarsUseridsMap, userNamesUseridsMap); onMount(() => { renderChart(); @@ -23,9 +25,9 @@ }); // Re-render the chart when the tab regains focus - const handleFocus = () => { - renderChart(); - }; + // const handleFocus = () => { + // renderChart(); + // }; // Function to render the chart const renderChart = () => { @@ -37,7 +39,7 @@ }; // Listen for window focus event to trigger chart refresh - window.addEventListener("focus", handleFocus); + // window.addEventListener("focus", handleFocus); </script> <div class="h-screen"> diff --git a/src/lib/ui/time/supabase/views/Chart.svelte b/src/lib/ui/time/supabase/views/Chart.svelte new file mode 100644 index 000000000..561b50872 --- /dev/null +++ b/src/lib/ui/time/supabase/views/Chart.svelte @@ -0,0 +1,45 @@ +<script lang="ts"> + import { onMount, onDestroy } from "svelte"; + import type { Course } from "$lib/services/models/lo-types"; + import { LabPieChart } from "../analytics/piechart/lab-pie-chart"; + import { TopicPieChart } from "../analytics/piechart/topic-pie-chart"; + import type { Session } from "@supabase/supabase-js"; + + export let chartType: 'LabPieChart' | 'TopicPieChart'; + export let course: Course; + export let session: Session; + export let userIds: string[] = []; + export let multipleUsers: boolean = false; + + let chartInstance: LabPieChart | TopicPieChart | null = null; + + onMount(() => { + if (chartType === 'LabPieChart') { + 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(); + }); + + const renderChart = () => { + if (chartInstance) { + chartInstance.renderChart(); + } + }; + + onDestroy(() => { + if (chartInstance) { + chartInstance = null; + } + }); + + window.addEventListener("focus", renderChart); +</script> + +<div class="h-screen"> + <div id="chart" style="height: 100%; width: 100%;"></div> +</div> 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 @@ +<script lang="ts"> + import { onMount, onDestroy } from "svelte"; + import { LabHeatMapChart } from "../analytics/heatmap/lab-heat-map-chart"; + import { TopicHeatMapChart } from "../analytics/heatmap/topic-heat-map-chart"; + import type { Course } from "$lib/services/models/lo-types"; + import type { Session } from "@supabase/supabase-js"; + + export let course: Course; + export let session: Session; + export let userIds: string[] = []; + export let userNamesUseridsMap: Map<string, string> = new Map(); + export let multipleUsers: boolean = false; + export let chartType: "LabHeatMap" | "TopicHeatMap"; + + let chartInstance: LabHeatMapChart | TopicHeatMapChart | null = null; + + onMount(() => { + if (chartType === "LabHeatMap") { + chartInstance = new LabHeatMapChart(course, session, userIds, userNamesUseridsMap, multipleUsers); + } else if (chartType === "TopicHeatMap") { + chartInstance = new TopicHeatMapChart(course, session, userIds, userNamesUseridsMap, multipleUsers); + } else { + throw new Error(`Invalid chart type: ${chartType}`); + } + if (multipleUsers) { + //combined + const element = document.getElementById("combined-heatmap"); + if (!element) { + throw new Error("Element with ID 'combined-heatmap' not found"); + } + chartInstance.renderChart(element!); + } + + chartInstance.populateAndRenderData(); + }); + + onDestroy(() => { + if (chartInstance) { + chartInstance = null; + } + }); + + window.addEventListener("focus", () => { + if (chartInstance) { + const container = document.getElementById("heatmap-container"); + chartInstance.renderChart(container!); + } + }); +</script> + +<div class="h-screen flex flex-col"> + {#if multipleUsers} + <div id="heatmap-container" class="h-2/3 w-full overflow-y-scroll"></div> + <div id="combined-heatmap" class="h-1/3 w-full overflow-y-scroll"></div> + {:else} + <div id="heatmap-container" class="h-full w-full overflow-y-scroll"></div> + {/if} +</div> diff --git a/src/lib/ui/time/supabase/views/InstructorCalendarView.svelte b/src/lib/ui/time/supabase/views/InstructorCalendarView.svelte index 29cc0ae8c..0827d89f7 100644 --- a/src/lib/ui/time/supabase/views/InstructorCalendarView.svelte +++ b/src/lib/ui/time/supabase/views/InstructorCalendarView.svelte @@ -1,16 +1,16 @@ <script lang="ts"> import { onDestroy, onMount } from "svelte"; import { CalendarChart } from "../analytics/calendar"; - import type { Course } from "$lib/services/models/lo-types"; - export let course: Course; export let timeActiveMap: Map<string, Map<string, number>>; + export let userAvatarsUseridsMap: Map<string, string>; + export let userNamesUseridsMap: Map<string, string>; export let userIds: string[]; let calendarChart: CalendarChart | null; onMount(() => { - calendarChart = new CalendarChart(); + calendarChart = new CalendarChart(userAvatarsUseridsMap, userNamesUseridsMap); createAndRenderChart(); }); @@ -26,7 +26,7 @@ if (timeActiveMap.size > 0) { timeActiveMap.forEach((calendarMap, userId) => { calendarChart?.createChartContainer(userId); - calendarChart?.renderCombinedChart(course, calendarMap, userId); + calendarChart?.renderCombinedChart(calendarMap, userId); }); } }; diff --git a/src/lib/ui/time/supabase/views/InstructorLabView.svelte b/src/lib/ui/time/supabase/views/InstructorLabView.svelte index 9a108bba0..d1761034a 100644 --- a/src/lib/ui/time/supabase/views/InstructorLabView.svelte +++ b/src/lib/ui/time/supabase/views/InstructorLabView.svelte @@ -1,53 +1,13 @@ <script lang="ts"> - import { onDestroy, onMount } from "svelte"; import type { Course } from "$lib/services/models/lo-types"; - import { LabHeatMapChart } from "../analytics/lab-heat-map"; + import LabHeatMapChart from "./HeatMapChart.svelte"; import type { Session } from "@supabase/supabase-js"; export let course: Course; export let session: Session; export let userIds: string[]; - let labHeatMapChart: LabHeatMapChart | null; - labHeatMapChart = new LabHeatMapChart(course, session, userIds); - - onMount(() => { - labHeatMapChart?.populateUsersData(); - renderChart(); - }); - - // Destroy the chart instance when the component unmounts - onDestroy(() => { - if (labHeatMapChart) { - // Clean up resources if needed - labHeatMapChart = null; - } - }); - - // Re-render the chart when the tab regains focus - const handleFocus = () => { - renderChart(); - }; - - // Function to render the chart - const renderChart = () => { - if (labHeatMapChart) { - const container = labHeatMapChart.getChartContainer(); - if (container) labHeatMapChart.renderChart(container); - - const combinedLabData = labHeatMapChart.prepareCombinedLabData(userIds); - const element = document.getElementById("combined-heatmap"); - if (!element) { - throw new Error("Element with ID 'combined-heatmap' not found"); - } - labHeatMapChart.renderCombinedLabChart(element, combinedLabData, "Total Time: Labs"); - } - }; - - // Listen for window focus event to trigger chart refresh - window.addEventListener("focus", handleFocus); + export let userNamesUseridsMap: Map<string, string>; + export let multipleUsers: boolean = true; </script> -<div class="h-screen flex flex-col"> - <div id="heatmap-container" class="h-3/4 w-full overflow-y-scroll"></div> - <div id="combined-heatmap" class="h-1/4 w-full overflow-y-scroll"></div> -</div> +<LabHeatMapChart chartType="LabHeatMap" {course} {session} {userIds} {userNamesUseridsMap} {multipleUsers} /> diff --git a/src/lib/ui/time/supabase/views/InstructorLabViewBoxPlot.svelte b/src/lib/ui/time/supabase/views/InstructorLabViewBoxPlot.svelte index 24eab3b73..bd1b4acc8 100644 --- a/src/lib/ui/time/supabase/views/InstructorLabViewBoxPlot.svelte +++ b/src/lib/ui/time/supabase/views/InstructorLabViewBoxPlot.svelte @@ -2,9 +2,11 @@ import { onMount, onDestroy } from "svelte"; import { LabBoxPlotChart } from "../analytics/lab-box-plot"; import type { Course } from "$lib/services/models/lo-types"; + import { lab } from "d3"; export let course: Course; export let userIds: string[]; + export let userNamesUseridsMap: Map<string, string>; let labBoxPlot: LabBoxPlotChart | null = null; @@ -24,7 +26,7 @@ }; onMount(() => { - labBoxPlot = new LabBoxPlotChart(course, userIds); + labBoxPlot = new LabBoxPlotChart(course, userIds, userNamesUseridsMap); renderCharts(); window.addEventListener("focus", handleFocus); }); diff --git a/src/lib/ui/time/supabase/views/InstructorTopicView.svelte b/src/lib/ui/time/supabase/views/InstructorTopicView.svelte index db3e38d4e..1ce0d3f3e 100644 --- a/src/lib/ui/time/supabase/views/InstructorTopicView.svelte +++ b/src/lib/ui/time/supabase/views/InstructorTopicView.svelte @@ -1,54 +1,13 @@ <script lang="ts"> - import { onDestroy, onMount } from "svelte"; - import type { Course, Topic } from "$lib/services/models/lo-types"; - import { TopicHeatMapChart } from "../analytics/topic-heat-map"; + 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[]; - let topicHeatMapChart: TopicHeatMapChart | null; - topicHeatMapChart = new TopicHeatMapChart(course, session, userIds); - - onMount(() => { - topicHeatMapChart?.populateUsersData(); - renderChart(); - }); - - // Destroy the chart instance when the component unmounts - onDestroy(() => { - if (topicHeatMapChart) { - // Clean up resources if needed - topicHeatMapChart = null; - } - }); - - // Re-render the chart when the tab regains focus - const handleFocus = () => { - renderChart(); - }; - - // Function to render the chart - const renderChart = () => { - if (topicHeatMapChart) { - const container = topicHeatMapChart.getChartContainer(); - topicHeatMapChart.renderChart(container); - - //combined - const combinedTopicData = topicHeatMapChart.prepareCombinedTopicData(userIds); - const element = document.getElementById("combined-heatmap"); - if (!element) { - throw new Error("Element with ID 'combined-heatmap' not found"); - } - topicHeatMapChart.renderCombinedTopicChart(element, combinedTopicData, "Total Time: Topics"); - } - }; - - // Listen for window focus event to trigger chart refresh - window.addEventListener("focus", handleFocus); + export let userNamesUseridsMap: Map<string, string>; + export let multipleUsers: boolean = true; </script> -<div class="h-screen flex flex-col"> - <div id="heatmap-container" class="h-1/2 w-full overflow-y-scroll"></div> - <div id="combined-heatmap" class="h-1/2 w-full overflow-y-scroll"></div> -</div> +<TopicHeatMapChart chartType="TopicHeatMap" {course} {session} {userIds} {userNamesUseridsMap} {multipleUsers} /> diff --git a/src/lib/ui/time/supabase/views/InstructorTopicViewBoxPlot.svelte b/src/lib/ui/time/supabase/views/InstructorTopicViewBoxPlot.svelte index fc7bc89e1..84c7c0eb8 100644 --- a/src/lib/ui/time/supabase/views/InstructorTopicViewBoxPlot.svelte +++ b/src/lib/ui/time/supabase/views/InstructorTopicViewBoxPlot.svelte @@ -5,12 +5,13 @@ export let course: Course; export let userIds: string[]; + export let userNamesUseridsMap: Map<string, string>; let topicBoxPlotChart: TopicBoxPlotChart | null; // Initialise the charts and render them when the component mounts onMount(() => { - topicBoxPlotChart = new TopicBoxPlotChart(course, userIds); + topicBoxPlotChart = new TopicBoxPlotChart(course, userIds, userNamesUseridsMap); renderCharts(); }); diff --git a/src/lib/ui/time/supabase/views/InstructorTopicViewPieChart.svelte b/src/lib/ui/time/supabase/views/InstructorTopicViewPieChart.svelte index 2c77561e1..4d7494e3f 100644 --- a/src/lib/ui/time/supabase/views/InstructorTopicViewPieChart.svelte +++ b/src/lib/ui/time/supabase/views/InstructorTopicViewPieChart.svelte @@ -1,36 +1,12 @@ <script lang="ts"> - import { onMount, onDestroy } from "svelte"; - import type { Course, Topic } from "$lib/services/models/lo-types"; - import { TopicPieChart } from "../analytics/topic-pie"; - import type { Session } from "@supabase/supabase-js"; + import type { Course } from "$lib/services/models/lo-types"; + import type { Session } from "@supabase/supabase-js"; + import Chart from './Chart.svelte'; export let course: Course; export let session: Session; export let userIds: string[]; const multipleUsers = true; - - let topicPieChart: TopicPieChart | null; - - onMount(() => { - topicPieChart = new TopicPieChart(course, session, userIds, multipleUsers); - renderChart(); - }); - - const renderChart = () => { - if (topicPieChart) { - topicPieChart.renderChart(userIds); - } - }; - - onDestroy(() => { - if (topicPieChart) { - topicPieChart = null; - } - }); - - window.addEventListener("focus", renderChart); </script> -<div class="h-screen"> - <div id={"chart"} style="height: 100%; width:100%"></div> -</div> +<Chart chartType="TopicPieChart" {course} {session} {userIds} {multipleUsers} /> \ No newline at end of file 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 @@ <script lang="ts"> - import { onDestroy, onMount } from "svelte"; - import type { Course, Lo } from "$lib/services/models/lo-types"; - import { LabHeatMapChart } from "../analytics/lab-heat-map"; - import type { Session } from "@supabase/supabase-js"; + import type { Course } from "$lib/services/models/lo-types"; + import LabHeatMapChart from "./HeatMapChart.svelte"; + import type { Session } from "@supabase/supabase-js"; export let course: Course; - export let session: Session + export let session: Session; export let userIds: string[]; - - let labHeatMapChart: LabHeatMapChart | null; - labHeatMapChart = new LabHeatMapChart(course, session, userIds); - - onMount(() => { - labHeatMapChart?.populateSingleUserData(); - renderChart(); - }); - - // Destroy the chart instance when the component unmounts - onDestroy(() => { - if (labHeatMapChart) { - labHeatMapChart = null; - } - }); - - // Re-render the chart when the tab regains focus - const handleFocus = () => { - renderChart(); - }; - - // Function to render the chart - const renderChart = () => { - if (labHeatMapChart && course) { - const container = labHeatMapChart.getChartContainer(); - labHeatMapChart.renderChart(container!); - } - }; - - // Listen for window focus event to trigger chart refresh - window.addEventListener("focus", handleFocus); + export let userNamesUseridsMap: Map<string, string>; + export let multipleUsers: boolean = false; </script> -<div class="h-screen"> - <div id={"heatmap-container"} style="height: 100%; width:100%"></div> -</div> +<LabHeatMapChart chartType="LabHeatMap" {course} {session} {userIds} {userNamesUseridsMap} {multipleUsers} /> diff --git a/src/lib/ui/time/supabase/views/LabViewPieChart.svelte b/src/lib/ui/time/supabase/views/LabViewPieChart.svelte index bb9a12ec3..2df6422ad 100644 --- a/src/lib/ui/time/supabase/views/LabViewPieChart.svelte +++ b/src/lib/ui/time/supabase/views/LabViewPieChart.svelte @@ -1,41 +1,10 @@ <script lang="ts"> - import { onMount, onDestroy } from "svelte"; - import type { Course, Lo } from "$lib/services/models/lo-types"; - import { LabPieChart } from "../analytics/lab-pie"; + import type { Course } from "$lib/services/models/lo-types"; import type { Session } from "@supabase/supabase-js"; - export let course: Course; - export let session:Session; - - let labPieChart: LabPieChart | null; - - onMount(async () => { - labPieChart = new LabPieChart(course, session); - labPieChart.renderChart(); - }); - - // Destroy the chart instance when the component unmounts - onDestroy(() => { - if (labPieChart) { - labPieChart = null; - } - }); + import Chart from './Chart.svelte'; - // Function to render the chart - const renderChart = () => { - if (labPieChart) { - labPieChart.renderChart(); - } - }; - - // Re-render the chart when the tab regains focus - const handleFocus = () => { - renderChart(); - }; - - // Listen for window focus event to trigger chart refresh - window.addEventListener("focus", handleFocus); + export let course: Course; + export let session: Session; </script> -<div class="h-screen"> - <div id="chart" style="height: 100%; width:100%"></div> -</div> +<Chart chartType="LabPieChart" {course} {session} /> \ No newline at end of file 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 @@ <script lang="ts"> - import { onDestroy, onMount } from "svelte"; - import type { Course, Topic } from "$lib/services/models/lo-types"; - import { TopicHeatMapChart } from "../analytics/topic-heat-map"; - import type { Session } from "@supabase/supabase-js"; - - export let course: Course; - export let session: Session; - export let userIds: string[] = []; - - let topicHeatMapChart: TopicHeatMapChart | null; - topicHeatMapChart = new TopicHeatMapChart(course, session, userIds); - - onMount(() => { - topicHeatMapChart?.populateSingleUserData(); - renderChart(); - }); - - onDestroy(() => { - if (topicHeatMapChart) { - topicHeatMapChart = null; - } - }); - - const renderChart = () => { - if (topicHeatMapChart && course) { - const container = topicHeatMapChart.getChartContainer(); - topicHeatMapChart.renderChart(container); - } - }; - - window.addEventListener('focus', renderChart); - </script> - - <div class="h-screen"> - <div id={"heatmap-container"} style="height: 100%; width:100%"></div> -</div> - - \ 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<string, string>; + export let multipleUsers: boolean = false; +</script> + +<TopicHeatMapChart chartType="TopicHeatMap" {course} {session} {userIds} {userNamesUseridsMap} {multipleUsers} /> diff --git a/src/lib/ui/time/supabase/views/TopicViewPieChart.svelte b/src/lib/ui/time/supabase/views/TopicViewPieChart.svelte index e3f5ad6c8..f836a44cd 100644 --- a/src/lib/ui/time/supabase/views/TopicViewPieChart.svelte +++ b/src/lib/ui/time/supabase/views/TopicViewPieChart.svelte @@ -1,36 +1,12 @@ <script lang="ts"> - import { onDestroy, onMount } from "svelte"; import type { Course } from "$lib/services/models/lo-types"; - import { TopicPieChart } from "../analytics/topic-pie"; - import type { Session } from "@supabase/supabase-js"; + import type { Session } from "@supabase/supabase-js"; + import Chart from './Chart.svelte'; export let course: Course; export let session: Session; export let userIds: string[]; const multipleUsers = false; - let topicPieChart: TopicPieChart | null; - topicPieChart = new TopicPieChart(course, session, userIds, multipleUsers); - - onMount(async () => { - topicPieChart?.renderChart(); - - }); - - const renderChart = () => { - if (topicPieChart) { - topicPieChart.renderChart(); - } - }; - - onDestroy(() => { - if (topicPieChart) { - topicPieChart = null; - } - }); - - window.addEventListener("focus", renderChart); </script> -<div class="h-screen"> - <div id="chart" style="height: 100%; width:100%"></div> -</div> +<Chart chartType="TopicPieChart" {course} {session} {userIds} {multipleUsers} /> diff --git a/src/routes/(course-reader)/topic/[courseid]/[...loid]/+page.ts b/src/routes/(course-reader)/topic/[courseid]/[...loid]/+page.ts index 7bc78cc3b..8648a5f4a 100644 --- a/src/routes/(course-reader)/topic/[courseid]/[...loid]/+page.ts +++ b/src/routes/(course-reader)/topic/[courseid]/[...loid]/+page.ts @@ -1,33 +1,14 @@ import type { PageLoad } from "./$types"; import { courseService } from "$lib/services/course"; -import { currentLo } from "$lib/stores"; -import type { Composite, Topic } from "$lib/services/models/lo-types"; +import type { Topic } from "$lib/services/models/lo-types"; export const ssr = false; export const load: PageLoad = async ({ params, url, fetch }) => { let topicId = url.pathname; - let unitId = ""; - let unitPos = topicId.indexOf("/unit"); - if (unitPos !== -1) { - unitId = topicId.slice(unitPos + 1); - topicId = topicId.slice(0, unitPos); - } - let sidePos = topicId.indexOf("/side"); - if (sidePos !== -1) { - unitId = topicId.slice(sidePos + 1); - topicId = topicId.slice(0, sidePos); - } - const topic = await courseService.readTopic(params.courseid, topicId, fetch) as Topic; - if (unitPos !== -1) { - const unitLo = topic.los.filter((lo) => lo.id == unitId); - currentLo.set(unitLo[0]); - } else { - currentLo.set(topic); - unitId = ""; - } + const topic = (await courseService.readTopic(params.courseid, topicId, fetch)) as Topic; return { - topic: topic, + topic: topic }; }; diff --git a/src/routes/(time)/next-time/[courseid]/+page.svelte b/src/routes/(time)/next-time/[courseid]/+page.svelte index 572754409..326fc9e0d 100644 --- a/src/routes/(time)/next-time/[courseid]/+page.svelte +++ b/src/routes/(time)/next-time/[courseid]/+page.svelte @@ -57,90 +57,163 @@ } </script> -<style> - .active { - @apply bg-gray-200 font-bold; - } -</style> - <div class="flex"> <!-- Side Tabs --> <div class="flex flex-col w-1/6 border-r border-gray-300"> - <button type="button" class="p-4 text-left" class:active={$storeTab === 'Calendar'} on:click={() => handleTabChange('Calendar')} on:keydown={(e) => e.key === 'Enter' && handleTabChange('Calendar')}>Calendar</button> - <button type="button" class="p-4 text-left" class:active={$storeTab === 'Topics'} on:click={() => handleTabChange('Topics')} on:keydown={(e) => e.key === 'Enter' && handleTabChange('Topics')}>Topics</button> - <button type="button" class="p-4 text-left" class:active={$storeTab === 'Labs'} on:click={() => handleTabChange('Labs')} on:keydown={(e) => e.key === 'Enter' && handleTabChange('Labs')}>Labs</button> + <button + type="button" + class="p-4 text-left" + class:active={$storeTab === "Calendar"} + on:click={() => handleTabChange("Calendar")} + on:keydown={(e) => e.key === "Enter" && handleTabChange("Calendar")}>Calendar</button + > + <button + type="button" + class="p-4 text-left" + class:active={$storeTab === "Topics"} + on:click={() => handleTabChange("Topics")} + on:keydown={(e) => e.key === "Enter" && handleTabChange("Topics")}>Topics</button + > + <button + type="button" + class="p-4 text-left" + class:active={$storeTab === "Labs"} + on:click={() => handleTabChange("Labs")} + on:keydown={(e) => e.key === "Enter" && handleTabChange("Labs")}>Labs</button + > </div> <!-- Content Area --> <div class="flex-grow p-4 w-4/5"> <!-- Top Tabs for Sub-items --> <div class="flex border-b border-gray-300 mb-4"> - {#if $storeTab === 'Topics'} + {#if $storeTab === "Topics"} {#if instructorMode} - <button type="button" class="p-2 text-left" class:active={$storeSubTab === 'InstructorTopicView'} on:click={() => handleSubTabChange('InstructorTopicView')} on:keydown={(e) => e.key === 'Enter' && handleSubTabChange('InstructorTopicView')}>Instructor Topic Time Heat-Map</button> - <button type="button" class="p-2 text-left" class:active={$storeSubTab === 'InstructorTopicViewPieChart'} on:click={() => handleSubTabChange('InstructorTopicViewPieChart')} on:keydown={(e) => e.key === 'Enter' && handleSubTabChange('InstructorTopicViewPieChart')}>Instructor Topic Time Pie-Chart</button> - <button type="button" class="p-2 text-left" class:active={$storeSubTab === 'InstructorTopicViewBoxPlot'} on:click={() => handleSubTabChange('InstructorTopicViewBoxPlot')} on:keydown={(e) => e.key === 'Enter' && handleSubTabChange('InstructorTopicViewBoxPlot')}>Instructor Topic Box Plot Chart</button> + <button + type="button" + class="p-2 text-left" + class:active={$storeSubTab === "InstructorTopicView"} + on:click={() => handleSubTabChange("InstructorTopicView")} + on:keydown={(e) => e.key === "Enter" && handleSubTabChange("InstructorTopicView")}>Instructor Topic Time Heat-Map</button + > + <button + type="button" + class="p-2 text-left" + class:active={$storeSubTab === "InstructorTopicViewPieChart"} + on:click={() => handleSubTabChange("InstructorTopicViewPieChart")} + on:keydown={(e) => e.key === "Enter" && handleSubTabChange("InstructorTopicViewPieChart")}>Instructor Topic Time Pie-Chart</button + > + <button + type="button" + class="p-2 text-left" + class:active={$storeSubTab === "InstructorTopicViewBoxPlot"} + on:click={() => handleSubTabChange("InstructorTopicViewBoxPlot")} + on:keydown={(e) => e.key === "Enter" && handleSubTabChange("InstructorTopicViewBoxPlot")}>Instructor Topic Box Plot Chart</button + > {:else} - <button type="button" class="p-2 text-left" class:active={$storeSubTab === 'TopicView'} on:click={() => handleSubTabChange('TopicView')} on:keydown={(e) => e.key === 'Enter' && handleSubTabChange('TopicView')}>Topic Time Heat-Map</button> - <button type="button" class="p-2 text-left" class:active={$storeSubTab === 'TopicViewPieChart'} on:click={() => handleSubTabChange('TopicViewPieChart')} on:keydown={(e) => e.key === 'Enter' && handleSubTabChange('TopicViewPieChart')}>Topic Time Pie-Chart</button> + <button + type="button" + class="p-2 text-left" + class:active={$storeSubTab === "TopicView"} + on:click={() => handleSubTabChange("TopicView")} + on:keydown={(e) => e.key === "Enter" && handleSubTabChange("TopicView")}>Topic Time Heat-Map</button + > + <button + type="button" + class="p-2 text-left" + class:active={$storeSubTab === "TopicViewPieChart"} + on:click={() => handleSubTabChange("TopicViewPieChart")} + on:keydown={(e) => e.key === "Enter" && handleSubTabChange("TopicViewPieChart")}>Topic Time Pie-Chart</button + > {/if} {/if} - {#if $storeTab === 'Labs'} + {#if $storeTab === "Labs"} {#if instructorMode} - <button type="button" class="p-2 text-left" class:active={$storeSubTab === 'InstructorLabView'} on:click={() => handleSubTabChange('InstructorLabView')} on:keydown={(e) => e.key === 'Enter' && handleSubTabChange('InstructorLabView')}>Instructor Lab Time Heat-Map</button> - <button type="button" class="p-2 text-left" class:active={$storeSubTab === 'InstructorLabViewBoxPlot'} on:click={() => handleSubTabChange('InstructorLabViewBoxPlot')} on:keydown={(e) => e.key === 'Enter' && handleSubTabChange('InstructorLabViewBoxPlot')}>Instructor Lab Time Box Plot Chart</button> + <button + type="button" + class="p-2 text-left" + class:active={$storeSubTab === "InstructorLabView"} + on:click={() => handleSubTabChange("InstructorLabView")} + on:keydown={(e) => e.key === "Enter" && handleSubTabChange("InstructorLabView")}>Instructor Lab Time Heat-Map</button + > + <button + type="button" + class="p-2 text-left" + class:active={$storeSubTab === "InstructorLabViewBoxPlot"} + on:click={() => handleSubTabChange("InstructorLabViewBoxPlot")} + on:keydown={(e) => e.key === "Enter" && handleSubTabChange("InstructorLabViewBoxPlot")}>Instructor Lab Time Box Plot Chart</button + > {:else} - <button type="button" class="p-2 text-left" class:active={$storeSubTab === 'LabView'} on:click={() => handleSubTabChange('LabView')} on:keydown={(e) => e.key === 'Enter' && handleSubTabChange('LabView')}>Lab Time Heat-Map</button> - <button type="button" class="p-2 text-left" class:active={$storeSubTab === 'LabViewPieChart'} on:click={() => handleSubTabChange('LabViewPieChart')} on:keydown={(e) => e.key === 'Enter' && handleSubTabChange('LabViewPieChart')}>Lab Time Pie-Chart</button> + <button + type="button" + class="p-2 text-left" + class:active={$storeSubTab === "LabView"} + on:click={() => handleSubTabChange("LabView")} + on:keydown={(e) => e.key === "Enter" && handleSubTabChange("LabView")}>Lab Time Heat-Map</button + > + <button + type="button" + class="p-2 text-left" + class:active={$storeSubTab === "LabViewPieChart"} + on:click={() => handleSubTabChange("LabViewPieChart")} + on:keydown={(e) => e.key === "Enter" && handleSubTabChange("LabViewPieChart")}>Lab Time Pie-Chart</button + > {/if} {/if} </div> <!-- Main Content --> <div class="w-full"> - {#if $storeTab === 'Calendar'} + {#if $storeTab === "Calendar"} {#if instructorMode} - <NewInstructorCalendarTime course={data.course} timeActiveMap={data.timeActiveMap} userIds={data.calendarIds} /> + <NewInstructorCalendarTime + timeActiveMap={data.timeActiveMap} + userIds={data.calendarIds} + userNamesUseridsMap={data.userNamesUseridsMap} + userAvatarsUseridsMap={data.userAvatarsUseridsMap} + /> {:else} <CalendarView timeActiveMap={data.timeActiveMap} session={data.session} medianTime={data.medianTime} /> {/if} - {:else if $storeTab === 'Topics'} + {:else if $storeTab === "Topics"} {#if instructorMode} - {#if $storeSubTab === 'InstructorTopicView'} - <InstructorTopicView course={data.course} session={data.session} userIds={data.userIds} /> - {:else if $storeSubTab === 'InstructorTopicViewPieChart'} + {#if $storeSubTab === "InstructorTopicView"} + <InstructorTopicView course={data.course} session={data.session} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> + {:else if $storeSubTab === "InstructorTopicViewPieChart"} <InstructorTopicViewPieChart course={data.course} session={data.session} userIds={data.userIds} /> - {:else if $storeSubTab === 'InstructorTopicViewBoxPlot'} - <InstructorTopicViewBoxPlot course={data.course} userIds={data.userIds} /> + {:else if $storeSubTab === "InstructorTopicViewBoxPlot"} + <InstructorTopicViewBoxPlot course={data.course} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> {:else} - <InstructorTopicView course={data.course} session={data.session} userIds={data.userIds} /> + <InstructorTopicView course={data.course} session={data.session} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> {/if} + {:else if $storeSubTab === "TopicView"} + <TopicView course={data.course} session={data.session} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> + {:else if $storeSubTab === "TopicViewPieChart"} + <TopicViewPieChart course={data.course} session={data.session} userIds={data.userIds} /> {:else} - {#if $storeSubTab === 'TopicView'} - <TopicView course={data.course} session={data.session} userIds={data.userIds} /> - {:else if $storeSubTab === 'TopicViewPieChart'} - <TopicViewPieChart course={data.course} session={data.session} userIds={data.userIds} /> - {:else} - <TopicView course={data.course} session={data.session} userIds={data.userIds} /> - {/if} + <TopicView course={data.course} session={data.session} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> {/if} - {:else if $storeTab === 'Labs'} + {:else if $storeTab === "Labs"} {#if instructorMode} - {#if $storeSubTab === 'InstructorLabView'} - <InstructorLabView course={data.course} session={data.session} userIds={data.userIds} /> - {:else if $storeSubTab === 'InstructorLabViewBoxPlot'} - <InstructorLabViewBoxPlot course={data.course} userIds={data.userIds} /> + {#if $storeSubTab === "InstructorLabView"} + <InstructorLabView course={data.course} session={data.session} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> + {:else if $storeSubTab === "InstructorLabViewBoxPlot"} + <InstructorLabViewBoxPlot course={data.course} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> {:else} - <InstructorLabView course={data.course} session={data.session} userIds={data.userIds} /> + <InstructorLabView course={data.course} session={data.session} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> {/if} + {:else if $storeSubTab === "LabView"} + <LabView course={data.course} session={data.session} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> + {:else if $storeSubTab === "LabViewPieChart"} + <LabViewPieChart course={data.course} session={data.session} /> {:else} - {#if $storeSubTab === 'LabView'} - <LabView course={data.course} session={data.session} userIds={data.userIds} /> - {:else if $storeSubTab === 'LabViewPieChart'} - <LabViewPieChart course={data.course} session={data.session} /> - {:else} - <LabView course={data.course} session={data.session} userIds={data.userIds} /> - {/if} + <LabView course={data.course} session={data.session} userIds={data.userIds} userNamesUseridsMap={data.userNamesUseridsMap} /> {/if} {/if} </div> </div> </div> + +<style> + .active { + @apply bg-gray-200 font-bold; + } +</style> diff --git a/src/routes/(time)/next-time/[courseid]/+page.ts b/src/routes/(time)/next-time/[courseid]/+page.ts index 6c495f389..037245b4a 100644 --- a/src/routes/(time)/next-time/[courseid]/+page.ts +++ b/src/routes/(time)/next-time/[courseid]/+page.ts @@ -3,7 +3,7 @@ import { courseService } from "$lib/services/course"; import type { Course } from "$lib/services/models/lo-types"; import { aggregateTimeActiveByDate, decorateLearningRecords, fetchLearningInteractions } from "$lib/services/utils/supabase-metrics"; import type { LearningInteraction } from "$lib/services/types/supabase-metrics"; -import { getCalendarDataForAll, getMedianTimeActivePerDate } from "$lib/services/utils/supabase-utils"; +import { getCalendarDataForAll, getGithubAvatarUrl, getMedianTimeActivePerDate, getUserNames } from "$lib/services/utils/supabase-utils"; export const ssr = false; @@ -13,6 +13,8 @@ export const load: PageLoad = async ({ parent, params, fetch }) => { const course: Course = await courseService.readCourse(params.courseid, fetch); const metrics: LearningInteraction[] = await fetchLearningInteractions(course); const userIds: string[] = [...new Set(metrics.map((m: LearningInteraction) => m.studentid))] as string[]; + const userNamesUseridsMap: Map<string, string> = await getUserNames(userIds); + const userAvatarsUseridsMap: Map<string, string> = await getGithubAvatarUrl(userIds); const records: LearningInteraction[] = await getCalendarDataForAll(course.courseId); const aggregatedData = await aggregateTimeActiveByDate(records); const medianCalendarTime = await getMedianTimeActivePerDate(course.courseId); @@ -38,6 +40,8 @@ export const load: PageLoad = async ({ parent, params, fetch }) => { return { course: course, userIds: userIds, + userNamesUseridsMap: userNamesUseridsMap, + userAvatarsUseridsMap: userAvatarsUseridsMap, calendarIds: calendarIds, // Because of mismatch data in the dbs (reintroduce calendar) session: data.session, timeActiveMap: aggregatedData,