@@ -119,7 +119,6 @@ function polynomialLineForGroup(group, order = 4) {
119119 return null
120120 }
121121
122- // Ensure numeric + sorted
123122 const pts = ( group . data || [ ] )
124123 . map ( p => ( { x : Number ( p . x ) , y : Number ( p . y ) } ) )
125124 . filter ( p => Number . isFinite ( p . x ) && Number . isFinite ( p . y ) )
@@ -133,20 +132,20 @@ function polynomialLineForGroup(group, order = 4) {
133132 const span = maxX - minX
134133 if ( span === 0 ) return null
135134
136- // Map x -> t in [-1, 1]
137135 const toT = ( x ) => ( ( x - minX ) / span ) * 2 - 1
138136 const toX = ( t ) => minX + ( ( t + 1 ) / 2 ) * span
139137
140- // Fit polynomial on t to avoid instability
141138 const data = pts . map ( p => [ toT ( p . x ) , p . y ] )
142139 const result = window . regression . polynomial ( data , { order } )
143140
144- // Sample a smooth curve in t-space
145141 const steps = 200
146142 const curve = [ ]
143+
147144 for ( let i = 0 ; i <= steps ; i ++ ) {
148145 const t = - 1 + ( 2 * i ) / steps
149- const y = result . predict ( t ) [ 1 ]
146+ const rawY = result . predict ( t ) [ 1 ]
147+ const y = Math . max ( 1 , Math . min ( 7 , rawY ) )
148+
150149 curve . push ( { x : toX ( t ) , y } )
151150 }
152151
@@ -196,6 +195,97 @@ function buildDatasetsForGroups(groups) {
196195 return datasets
197196}
198197
198+ function buildDatasetsForStudentReportByGroups ( groupedData ) {
199+ const source = groupedData ? groupedData : { }
200+ const datasets = [ ]
201+
202+ Object . entries ( source ) . forEach ( ( [ groupId , rows ] , idx ) => {
203+ const color = colorForIndex ( idx )
204+ const firstRow = rows [ 0 ] || { }
205+
206+ const points = rows
207+ . map ( ( row ) => {
208+ const x = new Date ( row . lesson_date ) . getTime ( )
209+ const y = row . average_mark
210+
211+ return {
212+ x,
213+ y,
214+ date : row . lesson_date ,
215+ lesson_url : row . lesson_url ,
216+ group_id : row . group_id ,
217+ group_name : row . group_name
218+ }
219+ } )
220+
221+ if ( ! points . length ) return
222+
223+ datasets . push ( {
224+ label : firstRow . group_name || `Group ${ groupId } ` ,
225+ groupKey : groupId ,
226+ type : "line" ,
227+ data : points ,
228+ parsing : false ,
229+ borderColor : color ,
230+ backgroundColor : color ,
231+ borderWidth : 3 ,
232+ tension : 0.15 ,
233+ spanGaps : false ,
234+ pointRadius : 3 ,
235+ pointHoverRadius : 5
236+ } )
237+ } )
238+
239+ return datasets
240+ }
241+
242+ function buildRegressionOnlyDatasetsForStudentSkills ( skillSeriesJson , opts = { } ) {
243+ const items = Array . isArray ( skillSeriesJson ) ? skillSeriesJson : [ ]
244+ const datasets = [ ]
245+
246+ items . forEach ( ( item , idx ) => {
247+ const skillName = item ?. skill || `Skill ${ idx + 1 } `
248+ const firstSeries = Array . isArray ( item ?. series ) ? item . series [ 0 ] : null
249+ const color = firstSeries ?. color || colorForIndex ( idx )
250+
251+ const points = ( firstSeries ?. data || [ ] )
252+ . map ( ( p ) => ( {
253+ x : p . x ,
254+ y : p . y ,
255+ lesson_url : p . lesson_url ,
256+ date : p . date
257+ } ) )
258+ . sort ( ( a , b ) => a . x - b . x )
259+
260+ if ( points . length < 2 ) return
261+
262+ const group = {
263+ id : skillName ,
264+ name : skillName ,
265+ data : points
266+ }
267+
268+ const curve = polynomialLineForGroup ( group , opts . regressionOrder ?? 4 )
269+ if ( ! curve ) return
270+
271+ datasets . push ( {
272+ label : skillName ,
273+ type : "line" ,
274+ data : curve ,
275+ parsing : false ,
276+ borderColor : color ,
277+ backgroundColor : color ,
278+ borderDash : [ 5 , 5 ] ,
279+ borderWidth : 3 ,
280+ tension : 0 ,
281+ pointRadius : 0 ,
282+ pointHoverRadius : 0
283+ } )
284+ } )
285+
286+ return datasets
287+ }
288+
199289// ---------- Group Analytics Chart && Average performance per Group by Lesson chart ---------
200290function displayAveragePerformancePerGroupByLessonChart ( containerId , seriesJson , opts = { } ) {
201291 if ( ! chartJsPresent ( ) ) return
@@ -674,12 +764,12 @@ function displayLessonChart(containerId, lessonId, data) {
674764 } ,
675765 pointBackgroundColor : ( ctx ) => {
676766 const raw = ctx . raw
677- if ( ! raw ) return " #9C27B0"
767+ if ( ! raw ) return "#9C27B0"
678768 return raw . lesson_id === lessonId ? "#4CAF50" : " #9C27B0"
679769 } ,
680770 pointBorderColor : ( ctx ) => {
681771 const raw = ctx . raw
682- if ( ! raw ) return " #9C27B0"
772+ if ( ! raw ) return "#9C27B0"
683773 return raw . lesson_id === lessonId ? "#4CAF50" : " #9C27B0"
684774 } ,
685775 pointHoverRadius : 5
@@ -1233,4 +1323,175 @@ function displayMarkAveragesChart(containerId, data, opts = {}) {
12331323 } ,
12341324 plugins : [ whiteBackgroundPlugin ( ) ]
12351325 } )
1326+ }
1327+
1328+ // ---------- Student report chart: performance by lesson split by group ----------
1329+ function displayStudentReportPerformanceByGroupChart ( containerId , groupedData , opts = { } ) {
1330+ if ( ! chartJsPresent ( ) ) return
1331+
1332+ const canvas = ensureCanvasIsPresent ( containerId , { heightPx : opts . heightPx || 500 } )
1333+ if ( ! canvas ) return
1334+
1335+ destroyIfExists ( canvas )
1336+
1337+ const datasets = buildDatasetsForStudentReportByGroups ( groupedData )
1338+
1339+ new Chart ( canvas . getContext ( "2d" ) , {
1340+ data : { datasets } ,
1341+ options : {
1342+ responsive : true ,
1343+ maintainAspectRatio : false ,
1344+ clip : false ,
1345+ plugins : {
1346+ legend : {
1347+ display : true ,
1348+ labels : {
1349+ padding : 10 ,
1350+ font : { size : 12 }
1351+ }
1352+ } ,
1353+ tooltip : {
1354+ backgroundColor : "#ffffff" ,
1355+ titleColor : "#111827" ,
1356+ bodyColor : "#374151" ,
1357+ borderColor : "#D1D5DB" ,
1358+ borderWidth : 2 ,
1359+ cornerRadius : 3 ,
1360+ padding : 12 ,
1361+ titleFont : {
1362+ size : 14 ,
1363+ weight : "600"
1364+ } ,
1365+ bodyFont : {
1366+ size : 13
1367+ } ,
1368+ bodySpacing : 3 ,
1369+ callbacks : {
1370+ title : function ( context ) {
1371+ return context [ 0 ] . dataset . label
1372+ } ,
1373+ label : function ( context ) {
1374+ return [
1375+ `Date: ${ toUSdateFormat ( context . parsed . x ) } ` ,
1376+ `Average: ${ context . parsed . y } `
1377+ ]
1378+ }
1379+ }
1380+ } ,
1381+ whiteBackground : {
1382+ color : "white"
1383+ }
1384+ } ,
1385+ scales : {
1386+ x : {
1387+ type : "linear" ,
1388+ title : {
1389+ display : true ,
1390+ text : opts . xTitle || "Lesson date"
1391+ } ,
1392+ ticks : {
1393+ callback : ( value ) => toUSdateFormat ( Number ( value ) )
1394+ }
1395+ } ,
1396+ y : {
1397+ title : {
1398+ display : true ,
1399+ text : opts . yTitle || "Performance"
1400+ } ,
1401+ min : 1 ,
1402+ max : 7 ,
1403+ ticks : {
1404+ precision : 0
1405+ }
1406+ }
1407+ } ,
1408+ onClick : function ( event , elements ) {
1409+ if ( ! elements ?. length ) return
1410+
1411+ const el = elements [ 0 ]
1412+ const point = this . data . datasets [ el . datasetIndex ] . data [ el . index ]
1413+
1414+ if ( point ?. lesson_url ) window . open ( point . lesson_url , "_blank" )
1415+ } ,
1416+ onHover : function ( event , elements ) {
1417+ const c = event . native ?. target || event . chart ?. canvas
1418+ if ( ! c ) return
1419+ c . style . cursor = elements ?. length ? "pointer" : "default"
1420+ }
1421+ } ,
1422+ plugins : [ whiteBackgroundPlugin ( ) ]
1423+ } )
1424+ }
1425+
1426+ // ---------- Student report chart: performance by lesson split by skill ----------
1427+ function displayStudentSkillRegressionChart ( containerId , skillSeriesJson , opts = { } ) {
1428+ if ( ! chartJsPresent ( ) ) return
1429+
1430+ const canvas = ensureCanvasIsPresent ( containerId , { heightPx : opts . heightPx || 500 } )
1431+ if ( ! canvas ) return
1432+ destroyIfExists ( canvas )
1433+
1434+ const datasets = buildRegressionOnlyDatasetsForStudentSkills ( skillSeriesJson , opts )
1435+
1436+ new Chart ( canvas . getContext ( "2d" ) , {
1437+ data : { datasets } ,
1438+ options : {
1439+ responsive : true ,
1440+ maintainAspectRatio : false ,
1441+ clip : false ,
1442+ plugins : {
1443+ title : {
1444+ display : true ,
1445+ text : opts . title || "Student progress by skill" ,
1446+ font : { size : 20 }
1447+ } ,
1448+ legend : {
1449+ display : true ,
1450+ labels : {
1451+ padding : 10 ,
1452+ font : { size : 11 } ,
1453+ boxHeight : 10 ,
1454+ boxWidth : 10
1455+ }
1456+ } ,
1457+ tooltip : {
1458+ backgroundColor : "#ffffff" ,
1459+ titleColor : "#111827" ,
1460+ bodyColor : "#374151" ,
1461+ borderColor : "#D1D5DB" ,
1462+ borderWidth : 2 ,
1463+ cornerRadius : 3 ,
1464+ padding : 12 ,
1465+ callbacks : {
1466+ title : ( ctx ) => ctx [ 0 ] ?. dataset ?. label || "" ,
1467+ label : ( ctx ) => [
1468+ `${ opts . xTitle || "Nr. of lessons" } : ${ Math . round ( ctx . parsed . x ) } ` ,
1469+ `${ opts . yTitle || "Performance" } : ${ ctx . parsed . y . toFixed ( 2 ) } `
1470+ ]
1471+ }
1472+ } ,
1473+ whiteBackground : { color : "white" }
1474+ } ,
1475+ scales : {
1476+ x : {
1477+ type : "linear" ,
1478+ min : 1 ,
1479+ title : { display : true , text : opts . xTitle || "Nr. of lessons" } ,
1480+ ticks : { precision : 0 }
1481+ } ,
1482+ y : {
1483+ min : 1 ,
1484+ max : 7 ,
1485+ title : { display : true , text : opts . yTitle || "Performance" } ,
1486+ ticks : { precision : 0 }
1487+ }
1488+ } ,
1489+ onHover : function ( event ) {
1490+ const c = event . native ?. target || event . chart ?. canvas
1491+ if ( ! c ) return
1492+ c . style . cursor = "default"
1493+ }
1494+ } ,
1495+ plugins : [ whiteBackgroundPlugin ( ) ]
1496+ } )
12361497}
0 commit comments