diff --git a/README.md b/README.md index 3af4a1866..3d05adbc4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,30 @@ For development, besides the JDK, addtitionally [NPM](https://www.npmjs.com/) an We kindly ask you to refer to the following paper in publications mentioning or employing DRES: +> Loris Sauter, Ralph Gasser, Heiko Schuldt, Abraham Bernstein, and Luca Rossetto. 2024. Performance Evaluation in Multimedia Retrieval. ACM Transactions on Multimedia Computing, Communications and Applications. https://doi.org/10.1145/3678881 + +**Link:** https://dl.acm.org/doi/10.1145/3678881 + +**Bibtex:** + +``` +@article{10.1145/3678881, +author = {Sauter, Loris and Gasser, Ralph and Schuldt, Heiko and Bernstein, Abraham and Rossetto, Luca}, +title = {Performance Evaluation in Multimedia Retrieval}, +year = {2024}, +publisher = {Association for Computing Machinery}, +address = {New York, NY, USA}, +issn = {1551-6857}, +url = {https://doi.org/10.1145/3678881}, +doi = {10.1145/3678881}, +journal = {ACM Transactions on Multimedia Computing, Communications and Applications}, +month = oct, +keywords = {Interactive Multimedia Retrieval, Retrieval Evaluation, Interactive Evaluation, Evaluation System} +} +``` + +We also have a demo publication: + > Rossetto L., Gasser R., Sauter L., Bernstein A., Schuldt H. (2021) A System for Interactive Multimedia Retrieval Evaluations. In: Lokoč J. et al. (eds) MultiMedia Modeling. MMM 2021. Lecture Notes in Computer Science, vol 12573. Springer, Cham. **Link:** https://doi.org/10.1007/978-3-030-67835-7_33 diff --git a/frontend/src/app/error/error-dialog.service.ts b/frontend/src/app/error/error-dialog.service.ts index 019cdcdaf..820aaad63 100644 --- a/frontend/src/app/error/error-dialog.service.ts +++ b/frontend/src/app/error/error-dialog.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { MatDialog } from "@angular/material/dialog"; import { ErrorDialogComponent } from "./error-dialog/error-dialog.component"; +import { environment } from "../../environments/environment"; @Injectable() export class ErrorDialogService { @@ -12,6 +13,11 @@ export class ErrorDialogService { } openDialog(message: string):void { + if (!environment.showErrorPopups) { + console.warn('[Silent Error]:', message); + return; + } + if(!this.opened){ this.opened = true; const dialogRef = this.dialog.open(ErrorDialogComponent, { diff --git a/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.html b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.html new file mode 100644 index 000000000..2955903ea --- /dev/null +++ b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.html @@ -0,0 +1,31 @@ +
+ +
+
+
+ {{ group.name }} +
+
+ +
+
+ +
+ {{ team.name }} +
+ +
+
+
+
+
+
+ +
+
+
\ No newline at end of file diff --git a/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.scss b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.scss new file mode 100644 index 000000000..aa2f82db1 --- /dev/null +++ b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.scss @@ -0,0 +1,92 @@ +:host { + display: block; + position: relative; + height: 100%; + width: 100%; +} + +.graph-wrapper { + position: absolute; + top: 0; bottom: 0; left: 0; right: 0; + display: flex; + flex-direction: column; + background: rgba(0, 0, 0, 0.1); + padding: 10px; + box-sizing: border-box; +} + +/* --- LEGEND --- */ +.legend-container { + display: flex; + flex-wrap: wrap; + gap: 15px; + padding-bottom: 8px; + border-bottom: 1px solid #444; + margin-bottom: 8px; + flex-shrink: 0; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; + + .color-swatch { width: 10px; height: 10px; border-radius: 2px; } + .group-name { color: #ccc; font-size: 11px; text-transform: uppercase; font-weight: 600; } +} + +/* --- SCROLLABLE CHART --- */ +.chart-scroll-area { + flex: 1; + overflow-y: auto; + padding-right: 5px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.chart-row { + display: flex; + align-items: center; + gap: 6px; + height: 12px; + + &:hover .track { filter: brightness(1.2); } +} + +.row-label { + width: 55px; + flex-shrink: 0; + display: flex; + align-items: center; + + .name { + flex: 1; + font-size: 11px; + color: #bbb; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.track-container { + flex: 1; +} + +.track { + width: 100%; + height: 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 1px; + display: flex; + overflow: hidden; +} + +.segment { + height: 100%; + transition: width 0.4s ease-in-out; + cursor: pointer; + + &:hover { filter: brightness(1.3); } +} \ No newline at end of file diff --git a/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.ts b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.ts new file mode 100644 index 000000000..ee33823bb --- /dev/null +++ b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.ts @@ -0,0 +1,80 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Observable, combineLatest, of } from 'rxjs'; +import { catchError, map, switchMap, filter } from 'rxjs/operators'; +import { ApiEvaluationInfo, ApiEvaluationState, EvaluationScoresService } from '../../../../openapi'; + +@Component({ + selector: 'app-compact-score-graph', + templateUrl: './compact-score-graph.component.html', + styleUrls: ['./compact-score-graph.component.scss'] +}) +export class CompactScoreGraphComponent implements OnInit { + @Input() info: Observable; + @Input() state: Observable; + + graphData$: Observable; + + colorPalette = [ + '#9b59b6', '#3498db', '#e67e22', '#2ecc71', '#e74c3c', + '#1abc9c', '#f1c40f', '#34495e', '#ff9ff3', '#feca57', + '#ff6b6b', '#48dbfb', '#1dd1a1', '#5f27cd', '#c8d6e5', + '#22a6b3', '#badc58', '#eb4d4b', '#686de0', '#30336b' + ]; + + constructor(private scoreService: EvaluationScoresService) {} + + ngOnInit(): void { + const rawScores$ = this.state.pipe( + filter(s => !!s && !!s.evaluationId), + switchMap(s => this.scoreService.getApiV2ScoreEvaluationByEvaluationId(s.evaluationId).pipe( + catchError(() => of([])) + )) + ); + + this.graphData$ = combineLatest([this.info, rawScores$]).pipe( + map(([info, scores]) => { + if (!info || !info.teams || !scores) return null; + + const validGroups = scores.filter(s => s.name !== 'sum' && s.name !== 'average'); + const legend = validGroups.map((g, i) => ({ + name: g.name, + color: this.colorPalette[i % this.colorPalette.length] + })); + + let globalMaxScore = 0; + + const teamsData = info.teams.map(team => { + let totalScore = 0; + const segments: any[] = []; + + validGroups.forEach((group, index) => { + const teamScoreObj = group.scores?.find(ss => ss.teamId === team.id); + const value = teamScoreObj ? Math.round(teamScoreObj.score) : 0; + + totalScore += value; + segments.push({ + name: group.name, + value: value, + color: this.colorPalette[index % this.colorPalette.length] + }); + }); + + if (totalScore > globalMaxScore) globalMaxScore = totalScore; + + return { name: team.name, total: totalScore, segments }; + }); + + teamsData.sort((a, b) => b.total - a.total); + const chartScaleMax = Math.max(globalMaxScore, 1000); + + teamsData.forEach(team => { + team.segments.forEach(seg => { + seg.widthInPercent = (seg.value / chartScaleMax) * 100; + }); + }); + + return { teams: teamsData, legend: legend }; + }) + ); + } +} \ No newline at end of file diff --git a/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.html b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.html new file mode 100644 index 000000000..95048344c --- /dev/null +++ b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.html @@ -0,0 +1,30 @@ +
+
+ +
+
+ #{{ team.rank }} +

{{ team.name }}

+
+ +

+ {{ team.score | number: '1.0-0' }} +

+ +

+ {{ team.correct }} + {{ team.wrong }} + {{ team.indeterminate }} +

+
+ +
+
\ No newline at end of file diff --git a/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.scss b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.scss new file mode 100644 index 000000000..74afc8436 --- /dev/null +++ b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.scss @@ -0,0 +1,95 @@ +:host { + display: block; + width: 100%; + height: 100%; + overflow: hidden; +} + +.compact-teams-container { + height: 100%; + padding: 8px; + box-sizing: border-box; + overflow-y: auto; /* Scroll if 50 teams don't fit vertically */ +} + +.teams-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 6px; + align-content: start; +} + +.tile { + min-height: 70px; + padding: 4px; + border-radius: 6px; + border: 2px solid #333; + background: #2c2c2e; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: space-between; + + .tile-header { + display: flex; + align-items: center; + gap: 6px; + background: rgba(0, 0, 0, 0.2); + padding: 2px 4px; + border-radius: 4px; + + .rank-badge { + background: #444; + color: #fff; + font-size: 10px; + font-weight: bold; + padding: 2px 4px; + border-radius: 4px; + flex-shrink: 0; + } + + h3 { + font-size: clamp(10px, 1.1vw, 13px); + margin: 0; + font-weight: 600; + color: #eee; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + p.score { + font-size: clamp(16px, 1.8vw, 20px); + font-weight: bold; + color: #fff; + margin: 4px 0; + text-align: center; + &.dres-fix-correct { color: #00ff9d; } + } + + p.counter { + font-size: 11px; + font-weight: 600; + margin: 0; + display: flex; + justify-content: center; + gap: 8px; + + span.CORRECT { color: #2ecc71; } + span.WRONG { color: #e74c3c; } + span.INDETERMINATE { color: #95a5a6; } + } + + &.status-correct { + background: radial-gradient(circle at top left, rgba(46, 204, 113, 0.45) 0%, rgba(46, 204, 113, 0) 100%), #2c2c2e; + border-color: rgba(46, 204, 113, 0.8); + } + &.status-wrong { + background: radial-gradient(circle at top left, rgba(231, 76, 60, 0.45) 0%, rgba(231, 76, 60, 0) 100%), #2c2c2e; + border-color: rgba(231, 76, 60, 0.8); + } + &.gold { border-color: #FFD700; .tile-header .rank-badge { background: #FFD700; color: #000; } } + &.silver { border-color: #C0C0C0; .tile-header .rank-badge { background: #C0C0C0; color: #000; } } + &.bronze { border-color: #CD7F32; .tile-header .rank-badge { background: #CD7F32; color: #000; } } +} \ No newline at end of file diff --git a/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.ts b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.ts new file mode 100644 index 000000000..2a4d85c73 --- /dev/null +++ b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.ts @@ -0,0 +1,91 @@ +import { Component, Input, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy } from '@angular/core'; +import { Observable, combineLatest, of } from 'rxjs'; +import { catchError, map, switchMap, sampleTime, shareReplay, retry, startWith } from 'rxjs/operators'; +import { ApiEvaluationInfo, ApiEvaluationState, EvaluationScoresService, EvaluationService } from '../../../../openapi'; + +@Component({ + selector: 'app-compact-teams-viewer', + templateUrl: './compact-teams-viewer.component.html', + styleUrls: ['./compact-teams-viewer.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CompactTeamsViewerComponent implements OnInit, OnDestroy { + @Input() info: Observable; + @Input() state: Observable; + @Input() taskEnded: Observable; + + teamsData$: Observable; + private intervalId: any; + + constructor( + private evaluationService: EvaluationService, + private scoresService: EvaluationScoresService, + private ref: ChangeDetectorRef + ) { + this.ref.detach(); + this.intervalId = setInterval(() => this.ref.detectChanges(), 500); + } + + ngOnInit(): void { + // Fetch submissions every 2 seconds + const submissions$ = this.state.pipe( + sampleTime(2000), + switchMap(st => this.evaluationService.getApiV2EvaluationByEvaluationIdSubmissionList(st.evaluationId).pipe( + catchError(() => of([])) + )), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + const scores$ = this.state.pipe( + switchMap(st => this.scoresService.getApiV2ScoreEvaluationByEvaluationIdCurrent(st.evaluationId).pipe( + retry(3), + catchError(() => of(null)) + )), + map(sc => { + const scoreMap = new Map(); + if (sc && sc.scores) { + sc.scores.forEach(v => scoreMap.set(v.teamId, v.score)); + } + return scoreMap; + }), + startWith(new Map()), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + // Combine everything into a single array for the UI + this.teamsData$ = combineLatest([this.info, scores$, submissions$]).pipe( + map(([info, scoreMap, submissions]) => { + if (!info || !info.teams) return []; + + const teamsWithScores = info.teams.map(team => { + const teamSubmissions = submissions.filter(s => s.teamId === team.id).flatMap(s => s.answers); + + return { + id: team.id, + name: team.name, + score: scoreMap.get(team.id) || 0, + correct: teamSubmissions.filter(a => a.status === 'CORRECT').length, + wrong: teamSubmissions.filter(a => a.status === 'WRONG').length, + indeterminate: teamSubmissions.filter(a => a.status === 'INDETERMINATE').length, + rank: 0 // to be filled + }; + }); + + // Sort a copy by score to determine true ranks + const sortedByScore = [...teamsWithScores].sort((a, b) => b.score - a.score); + sortedByScore.forEach((team, index) => { + const original = teamsWithScores.find(t => t.id === team.id); + if (original) { + original.rank = index + 1; + } + }); + + return teamsWithScores; + }) + ); + } + + ngOnDestroy(): void { + clearInterval(this.intervalId); + } +} \ No newline at end of file diff --git a/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.html b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.html new file mode 100644 index 000000000..98f7dbe75 --- /dev/null +++ b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.html @@ -0,0 +1,19 @@ + +
+
+ +
+
{{ i + 1 }}
+
{{ player.userName }}
+
{{ player.totalScore }}
+
+ +
+
+
\ No newline at end of file diff --git a/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.scss b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.scss new file mode 100644 index 000000000..4bc407eea --- /dev/null +++ b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.scss @@ -0,0 +1,74 @@ +:host { + display: block; + position: relative; + height: 100%; + width: 100%; +} + +.leaderboard-wrapper { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + padding: 10px; + box-sizing: border-box; + overflow-y: auto; /* Scrollbar */ +} + +.leaderboard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 6px; + align-content: start; +} + +.player-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 6px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + border-left: 3px solid #444; + + .rank { + width: 20px; + font-size: 11px; + font-weight: bold; + color: #888; + } + + .name { + flex: 1; + font-size: 12px; + color: #eee; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 4px; + } + + .score { + font-size: 13px; + font-weight: bold; + color: #fff; + text-align: right; + } + + &.gold { + border-left-color: #FFD700; + background: linear-gradient(90deg, rgba(255, 215, 0, 0.15), transparent); + .rank { color: #FFD700; } + } + &.silver { + border-left-color: #C0C0C0; + background: linear-gradient(90deg, rgba(192, 192, 192, 0.15), transparent); + .rank { color: #C0C0C0; } + } + &.bronze { + border-left-color: #CD7F32; + background: linear-gradient(90deg, rgba(205, 127, 50, 0.15), transparent); + .rank { color: #CD7F32; } + } +} \ No newline at end of file diff --git a/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.ts b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.ts new file mode 100644 index 000000000..900fe7ada --- /dev/null +++ b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.ts @@ -0,0 +1,47 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Observable, combineLatest, of } from 'rxjs'; +import { catchError, map, switchMap, filter } from 'rxjs/operators'; +import { ApiEvaluationInfo, ApiEvaluationState, EvaluationScoresService } from '../../../../openapi'; + +@Component({ + selector: 'app-leaderboard-viewer', + templateUrl: './leaderboard-viewer.component.html', + styleUrls: ['./leaderboard-viewer.component.scss'] +}) +export class LeaderboardViewerComponent implements OnInit { + @Input() info: Observable; + @Input() state: Observable; + + playerData$: Observable; + + constructor(private scoreService: EvaluationScoresService) {} + + ngOnInit(): void { + const rawScores$ = this.state.pipe( + filter(s => !!s && !!s.evaluationId), + switchMap(s => this.scoreService.getApiV2ScoreEvaluationByEvaluationId(s.evaluationId).pipe( + catchError(() => of([])) + )) + ); + + this.playerData$ = combineLatest([this.info, rawScores$]).pipe( + map(([info, scores]) => { + if (!info || !info.teams || !scores || scores.length === 0) return []; + + const players = info.teams.map(team => { + let totalScore = 0; + // Tally up the total score from all groups, ignoring the 'sum' and 'average' meta-fields + scores.filter(s => s.name !== 'sum' && s.name !== 'average').forEach(group => { + const teamScoreObj = group.scores?.find(ss => ss.teamId === team.id); + if (teamScoreObj) totalScore += Math.round(teamScoreObj.score); + }); + + return { userName: team.name, totalScore: totalScore }; + }); + + // Return ALL players, sorted highest to lowest + return players.sort((a, b) => b.totalScore - a.totalScore); + }) + ); + } +} \ No newline at end of file diff --git a/frontend/src/app/viewer/model/run-viewer-preset.ts b/frontend/src/app/viewer/model/run-viewer-preset.ts new file mode 100644 index 000000000..95cdc8f1a --- /dev/null +++ b/frontend/src/app/viewer/model/run-viewer-preset.ts @@ -0,0 +1,10 @@ +export interface ViewerPreset { + name: string; + icon: string; + config: { + left: string; + center: string; + right: string; + bottom: string; + }; +} \ No newline at end of file diff --git a/frontend/src/app/viewer/model/run-viewer-widgets.ts b/frontend/src/app/viewer/model/run-viewer-widgets.ts index 1013e87f3..a43b5101a 100644 --- a/frontend/src/app/viewer/model/run-viewer-widgets.ts +++ b/frontend/src/app/viewer/model/run-viewer-widgets.ts @@ -16,10 +16,16 @@ export class Widget { new Widget('player', 'Player'), new Widget('competition_score', 'Normalized Competition Scores'), new Widget('task_type_score', 'Task Type Score'), + new Widget('scoreboard', 'Scoreboard'), + new Widget('recent_submissions', 'Recent Submissions'), + new Widget('compact_score_graph', 'Compact Score Graph'), ]; /** The {@link Widget}s available at the bottom of the viewer. */ - public static BOTTOM_WIDGETS: Array = [new Widget('team_score', 'Team Scores')]; + public static BOTTOM_WIDGETS: Array = [ + new Widget('team_score', 'Team Scores'), + new Widget('compact_team_score', 'Compact Team Scores'), + ]; /** Given a name and a default, this resolves and returns a {@link Widget} of the group {@link CENTER_WIDGETS} */ public static resolveCenterWidget(name: string, fallback: string) { diff --git a/frontend/src/app/viewer/recent-submissions/recent-submissions.component.html b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.html new file mode 100644 index 000000000..3f49e8e6b --- /dev/null +++ b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.html @@ -0,0 +1,30 @@ +
+
+

Recent Submissions

+
+ +
+
+ +
+ "{{ sub.previewText }}" +
+ +
+ Preview +
N/A
+
+
+ +
+ {{ sub.teamName }} + {{ sub.relativeTime }} +
+ +
+
+
\ No newline at end of file diff --git a/frontend/src/app/viewer/recent-submissions/recent-submissions.component.scss b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.scss new file mode 100644 index 000000000..2d6780d3a --- /dev/null +++ b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.scss @@ -0,0 +1,115 @@ +:host { + display: block; + position: relative; + height: 100%; + width: 100%; +} + +.feed-wrapper { + position: absolute; + top: 0; bottom: 0; left: 0; right: 0; + display: flex; + flex-direction: column; + background: rgba(0, 0, 0, 0.2); +} + +.feed-header { + padding: 8px 12px; + background: #222; + border-bottom: 1px solid #444; + flex-shrink: 0; + + h3 { + margin: 0; + font-size: 13px; + color: #eee; + text-transform: uppercase; + font-weight: 600; + } +} + +.feed-list { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.submission-card { + display: flex; + flex-direction: row; + align-items: center; + background: #2c2c2e; + border-radius: 4px; + overflow: hidden; + height: 44px; + flex-shrink: 0; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + + /* Status specific styles */ + &.CORRECT { border-left: 3px solid #2ecc71; .badge { color: #2ecc71; } } + &.WRONG { border-left: 3px solid #e74c3c; .badge { color: #e74c3c; } } + &.INDETERMINATE { border-left: 3px solid #95a5a6; .badge { color: #95a5a6; } } + + .preview-media { + width: 78px; + height: 100%; + background: #111; + flex-shrink: 0; + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + .no-media { + color: #666; + font-size: 10px; + text-align: center; + line-height: 44px; + } + } + + .preview-text { + width: 78px; + height: 100%; + background: #111; + color: #ddd; + font-style: italic; + font-size: 10px; + padding: 4px; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + } + + .card-details { + flex: 1; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 10px; + overflow: hidden; + + .team { + font-weight: bold; + color: #fff; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .time { + color: #aaa; + font-family: monospace; + font-size: 11px; + margin-left: 8px; + flex-shrink: 0; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/viewer/recent-submissions/recent-submissions.component.ts b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.ts new file mode 100644 index 000000000..c440ee78d --- /dev/null +++ b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.ts @@ -0,0 +1,119 @@ +import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { Observable, combineLatest, of } from 'rxjs'; +import { catchError, map, switchMap, filter, sampleTime, shareReplay } from 'rxjs/operators'; +import { ApiEvaluationInfo, ApiEvaluationState, ApiMediaItem, EvaluationService } from '../../../../openapi'; +import { AppConfig } from '../../app.config'; +import { HttpClient } from '@angular/common/http'; + +@Component({ + selector: 'app-recent-submissions', + templateUrl: './recent-submissions.component.html', + styleUrls: ['./recent-submissions.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RecentSubmissionsComponent implements OnInit { + @Input() info: Observable; + @Input() state: Observable; + + recentSubmissions$: Observable; + + private serverTimeOffset = 0; + private currentTaskId: string | null = null; + private currentTaskStartTime: number = 0; + + constructor( + private evaluationService: EvaluationService, + private config: AppConfig, + private http: HttpClient + ) {} + + ngOnInit(): void { + // Calculate the offset between server time and client time + this.http.get(this.config.resolveApiUrl('/client/evaluation/list'), { observe: 'response', responseType: 'text' }).subscribe(res => { + const serverDateStr = res.headers.get('Date'); + if (serverDateStr) { + const serverTime = new Date(serverDateStr).getTime(); + this.serverTimeOffset = Date.now() - serverTime; + } + }); + + const submissions$ = this.state.pipe( + sampleTime(2000), + switchMap(st => this.evaluationService.getApiV2EvaluationByEvaluationIdSubmissionList(st.evaluationId).pipe( + catchError(() => of([])) + )), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + this.recentSubmissions$ = combineLatest([this.info, submissions$, this.state]).pipe( + map(([info, submissions, state]) => { + if (!info || !info.teams || !submissions || !state) return []; + + const isRunning = state.taskStatus === 'RUNNING'; + + if (isRunning) { + if (this.currentTaskId !== state.taskId) { + this.currentTaskId = state.taskId; + const syncedServerTime = Date.now() - this.serverTimeOffset; + this.currentTaskStartTime = syncedServerTime - (state.timeElapsed * 1000); + } + } else { + this.currentTaskId = null; + } + + const teamMap = new Map(); + info.teams.forEach(t => teamMap.set(t.id, t.name)); + + const feed: any[] = []; + + submissions.forEach(sub => { + const teamName = teamMap.get(sub.teamId) || 'Unknown'; + + sub.answers.forEach((ans, index) => { + const firstAns = ans.answers && ans.answers.length > 0 ? ans.answers[0] : null; + + let timeDisplay = ''; + + // Branch for different time display logic based on whether the task is still running or not + if (isRunning && this.currentTaskStartTime > 0) { + // If running, display relative time since task start + const relativeMs = Math.max(0, sub.timestamp - this.currentTaskStartTime); + const totalSeconds = Math.floor(relativeMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = (totalSeconds % 60).toString().padStart(2, '0'); + timeDisplay = `${minutes}:${seconds}`; + } else { + // If ended, show absolute time + const d = new Date(sub.timestamp); + const hh = d.getHours().toString().padStart(2, '0'); + const mm = d.getMinutes().toString().padStart(2, '0'); + const ss = d.getSeconds().toString().padStart(2, '0'); + timeDisplay = `${hh}:${mm}:${ss}`; + } + + feed.push({ + uniqueId: `${sub.submissionId}-${index}`, + teamName: teamName, + status: ans.status, + type: firstAns?.type, + previewUrl: this.previewOfItem(firstAns?.item, firstAns?.start), + previewText: firstAns?.text, + relativeTime: timeDisplay + }); + }); + }); + + return feed.reverse().slice(0, 30); + }) + ); + } + + public previewOfItem(item: ApiMediaItem, start: number): string | null { + if (!item) return null; + return this.config.resolveApiUrl(`/preview/${item.mediaItemId}/${start == null ? 0 : start}`); + } + + public trackByUniqueId(index: number, sub: any): string { + return sub.uniqueId; + } +} \ No newline at end of file diff --git a/frontend/src/app/viewer/run-viewer.component.html b/frontend/src/app/viewer/run-viewer.component.html index e1738a3b7..97a871726 100644 --- a/frontend/src/app/viewer/run-viewer.component.html +++ b/frontend/src/app/viewer/run-viewer.component.html @@ -11,11 +11,25 @@
- + + + + + - + @@ -105,6 +119,21 @@ [info]="info" [state]="state" > + + +
+ + +
+ + +
@@ -174,6 +233,12 @@ [info]="info" [state]="state" > + diff --git a/frontend/src/app/viewer/run-viewer.component.ts b/frontend/src/app/viewer/run-viewer.component.ts index c2adc8e4c..1b9ddcd04 100644 --- a/frontend/src/app/viewer/run-viewer.component.ts +++ b/frontend/src/app/viewer/run-viewer.component.ts @@ -17,6 +17,7 @@ import { DOCUMENT } from '@angular/common'; import {Title} from '@angular/platform-browser'; import {ApiEvaluationInfo, ApiEvaluationState, EvaluationService} from '../../../openapi'; import {Overlay} from "@angular/cdk/overlay"; +import { ViewerPreset } from './model/run-viewer-preset'; @Component({ selector: 'app-run-viewer', @@ -57,6 +58,25 @@ export class RunViewerComponent implements OnInit, AfterViewInit, OnDestroy { noUi: Observable; + // Pre-defined layout configurations + layoutPresets: ViewerPreset[] = [ + { + name: 'Original View', + icon: 'dashboard', + config: { left: 'task_type_score', center: 'player', right: 'competition_score', bottom: 'team_score' } + }, + { + name: 'Compact View', + icon: 'view_compact', + config: { left: 'scoreboard', center: 'player', right: 'compact_score_graph', bottom: 'compact_team_score' } + }, + { + name: 'View with Submissions', + icon: 'assignment_turned_in', + config: { left: 'scoreboard', center: 'player', right: 'recent_submissions', bottom: 'compact_team_score' } + } + ]; + /** Cached config */ private p: any; @@ -223,6 +243,22 @@ export class RunViewerComponent implements OnInit, AfterViewInit, OnDestroy { this.router.navigate([this.router.url.substring(0, this.router.url.indexOf(';')), pCopy]); } + /** + * Applies a predefined layout configuration to the viewer. + * + * @param config The layout configuration to apply, containing widget assignments for each position ({@link ViewerPreset}). + */ + public applyLayoutPreset(config: ViewerPreset['config']) { + // Find the base URL by stripping off all current matrix params + let baseUrl = this.router.url; + if (baseUrl.includes(';')) { + baseUrl = baseUrl.substring(0, baseUrl.indexOf(';')); + } + + // Navigate to the base URL, appending the new configuration matrix + this.router.navigate([baseUrl, config]); + } + /** * Determines and returns the number of body {@link Widget}s. * diff --git a/frontend/src/app/viewer/viewer.module.ts b/frontend/src/app/viewer/viewer.module.ts index a4f898700..99b6d75e3 100644 --- a/frontend/src/app/viewer/viewer.module.ts +++ b/frontend/src/app/viewer/viewer.module.ts @@ -24,6 +24,10 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { SharedModule } from '../shared/shared.module'; import {FullscreenOverlayContainer, OverlayContainer, OverlayModule} from "@angular/cdk/overlay"; import { EvaluationModule } from "../evaluation/evaluation.module"; +import { LeaderboardViewerComponent } from './leaderboard-viewer/leaderboard-viewer.component'; +import { CompactTeamsViewerComponent } from './compact-teams-viewer/compact-teams-viewer.component'; +import { RecentSubmissionsComponent } from './recent-submissions/recent-submissions.component'; +import { CompactScoreGraphComponent } from './compact-score-graph/compact-score-graph.component'; @NgModule({ imports: [ @@ -51,7 +55,7 @@ import { EvaluationModule } from "../evaluation/evaluation.module"; EvaluationModule ], exports: [RunViewerComponent], - declarations: [RunViewerComponent, TaskViewerComponent, TeamsViewerComponent, ScoreboardViewerComponent], + declarations: [RunViewerComponent, TaskViewerComponent, TeamsViewerComponent, ScoreboardViewerComponent, LeaderboardViewerComponent, CompactTeamsViewerComponent, RecentSubmissionsComponent, CompactScoreGraphComponent], providers: [{provide: OverlayContainer, useClass: FullscreenOverlayContainer}], }) export class ViewerModule {} diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index c9669790b..b343130dd 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -1,3 +1,4 @@ export const environment = { production: true, + showErrorPopups: false, }; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 99c3763ca..6e01c517a 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -4,6 +4,7 @@ export const environment = { production: false, + showErrorPopups: true, }; /*