diff --git a/api/config/api-url.js b/api/config/api-url.js index 4e68b70..87a179e 100644 --- a/api/config/api-url.js +++ b/api/config/api-url.js @@ -9,6 +9,7 @@ module.exports = { command: rootRoute + '/cmd', decorations: rootRoute + '/decorations', overview: rootRoute + '/overview', + players: rootRoute + '/players', ranks: rootRoute + '/ranks', request: rootRoute + '/request', signatures: '/signatures', diff --git a/api/package.json b/api/package.json index 9cfdf82..b1c4c3f 100644 --- a/api/package.json +++ b/api/package.json @@ -17,7 +17,7 @@ "cors": "^2.8.3", "cron": "^1.2.1", "debug": "~2.2.0", - "express": "~4.13.1", + "express": "^4.16.1", "imagemin": "^5.2.2", "imagemin-pngquant": "^5.0.0", "jimp": "^0.2.27", diff --git a/api/routes/players.js b/api/routes/players.js new file mode 100644 index 0000000..c296f8d --- /dev/null +++ b/api/routes/players.js @@ -0,0 +1,56 @@ +"use strict"; + +// modules +const express = require('express'); +const logger = require('debug')('cc:wars'); + +// HTTP status codes by name +const codes = require('./http-codes'); + +const routerHandling = require('../middleware/router-handling'); + +// Mongoose Model using mongoDB +const CampaignModel = require('../models/campaign'); +const PlayerModel = require('../models/player'); +const WarModel = require('../models/war'); + +const campaignPlayer = express.Router(); + +// routes ********************** +campaignPlayer.route('/:campaignId/:playerName') + .get((req, res, next) => { + CampaignModel.findById(req.params.campaignId, (err, campaign) => { + if (err) return next(err); + WarModel.find({campaign: req.params.campaignId}, '_id', (err, wars) => { + if (err) return next(err); + const warIds = wars.map((obj) => { + return obj._id; + }); + PlayerModel.find({name: req.params.playerName, warId: {"$in": warIds}}) + .populate('warId') + .exec((err, items) => { + if (err) return next(err); + if (!items || items.length === 0) { + const err = new Error('unknown player name'); + err.status = codes.notfound; + return next(err) + } + res.locals.items = { + name: req.params.playerName, + campaign: campaign, + players: items + }; + next(); + }) + }) + }) + }) + + .all( + routerHandling.httpMethodNotAllowed + ); + + +campaignPlayer.use(routerHandling.emptyResponse); + +module.exports = campaignPlayer; diff --git a/api/server.js b/api/server.js index 9c203a9..b961de2 100644 --- a/api/server.js +++ b/api/server.js @@ -30,6 +30,7 @@ const rankRouter = require('./routes/ranks'); const decorationRouter = require('./routes/decorations'); const awardingRouter = require('./routes/awardings'); const requestRouter = require('./routes/request'); +const playerRouter = require('./routes/players'); const signatureRouter = require('./routes/signatures'); const commandRouter = require('./routes/command'); const campaignRouter = require('./routes/campaigns'); @@ -80,6 +81,7 @@ app.use(urls.decorations, decorationRouter); app.use(urls.request, requestRouter); app.use(urls.awards, awardingRouter); app.use(urls.wars, warRouter); +app.use(urls.players, playerRouter); app.use(urls.campaigns,campaignRouter); app.use(urls.command, apiAuthenticationMiddleware, checkAdmin, commandRouter); app.use(urls.account, apiAuthenticationMiddleware, checkAdmin, accountRouter); diff --git a/package.json b/package.json index 15f21aa..fe735aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opt-cc", - "version": "1.4.4", + "version": "1.5.0", "license": "MIT", "private": true, "scripts": { diff --git a/static/src/app/app.config.ts b/static/src/app/app.config.ts index 2eb172f..ad97dbe 100644 --- a/static/src/app/app.config.ts +++ b/static/src/app/app.config.ts @@ -11,6 +11,7 @@ export class AppConfig { public readonly apiSquadPath = this.apiUrl + '/squads/'; public readonly apiUserPath = this.apiUrl + '/users/'; public readonly apiOverviewPath = this.apiUrl + '/overview'; + public readonly apiPlayersPath = this.apiUrl + '/players'; public readonly apiRequestAwardPath = this.apiUrl + '/request/award'; public readonly apiPromotionPath = this.apiUrl + '/request/promotion'; public readonly apiWarPath = this.apiUrl + '/wars'; diff --git a/static/src/app/models/model-interfaces.ts b/static/src/app/models/model-interfaces.ts index a4ccc0a..d5b536f 100644 --- a/static/src/app/models/model-interfaces.ts +++ b/static/src/app/models/model-interfaces.ts @@ -29,6 +29,12 @@ export interface Player { flagTouch?: number; } +export interface CampaignPlayer { + name?: string; + campaign?: Campaign; + players?: Player[]; +} + export interface Campaign { _id?: string; title?: string; diff --git a/static/src/app/services/player-service/player.service.ts b/static/src/app/services/player-service/player.service.ts new file mode 100644 index 0000000..ae231cc --- /dev/null +++ b/static/src/app/services/player-service/player.service.ts @@ -0,0 +1,18 @@ +import {Injectable} from "@angular/core"; +import {AppConfig} from "../../app.config"; +import {HttpClient} from "../http-client"; + +@Injectable() +export class PlayerService { + + constructor(private http: HttpClient, + private config: AppConfig) { + } + + getCampaignPlayer(campaignId: string, playerName: string) { + return this.http.get(this.config.apiPlayersPath + '/' + campaignId + '/' + playerName) + .map(res => res.json()) + } + +} + diff --git a/static/src/app/statistic/campaign-player-detail/campaign-player-detail.component.css b/static/src/app/statistic/campaign-player-detail/campaign-player-detail.component.css new file mode 100644 index 0000000..0332e44 --- /dev/null +++ b/static/src/app/statistic/campaign-player-detail/campaign-player-detail.component.css @@ -0,0 +1,58 @@ +.overview { + position: fixed; + overflow-y: scroll; + overflow-x: hidden; + border-left: thin solid lightgrey; + bottom: 20px; + width: 80%; + padding-left: 20px; + padding-right: 5%; + padding-top: 70px; + height: 100vh; +} + +h2 { + padding: 10px; +} + +.btn-back { + clear: both; + float: left; + width: 120px; + margin-left: 10px; +} + +.sum-container { + width: 100%; + margin: auto; + clear: left; + padding: 2%; +} + +.gauge-container { + padding: 35px; +} + +.sum-bar-container { + width: 70%; + height: 400px; + margin: auto; + padding-left: 4%; +} + +.charts-parent { + clear: left; + padding-top: 50px; + width: 100%; + margin: auto; +} + +.chart-container { + width: 42%; + min-width: 500px; + height: 300px; + padding: 15px; + margin: 2%; + float: left; +} + diff --git a/static/src/app/statistic/campaign-player-detail/campaign-player-detail.component.html b/static/src/app/statistic/campaign-player-detail/campaign-player-detail.component.html new file mode 100644 index 0000000..9542f3f --- /dev/null +++ b/static/src/app/statistic/campaign-player-detail/campaign-player-detail.component.html @@ -0,0 +1,167 @@ +
+ +

Spielerstatistik - {{campaignPlayer.name}}

+

{{campaignPlayer.campaign.title}} Kampagne

+ + < Zurück + + +
+
+ + + + + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
diff --git a/static/src/app/statistic/campaign-player-detail/campaign-player-detail.component.ts b/static/src/app/statistic/campaign-player-detail/campaign-player-detail.component.ts new file mode 100644 index 0000000..7ccd760 --- /dev/null +++ b/static/src/app/statistic/campaign-player-detail/campaign-player-detail.component.ts @@ -0,0 +1,178 @@ +import {Component} from "@angular/core"; +import {ActivatedRoute} from "@angular/router"; +import {CampaignPlayer} from "../../models/model-interfaces"; +import {PlayerService} from "../../services/player-service/player.service"; +import {ChartUtils} from "../../utils/chart-utils"; +import {Location} from '@angular/common'; + + +@Component({ + selector: 'campaign-player-detail', + templateUrl: './campaign-player-detail.component.html', + styleUrls: ['./campaign-player-detail.component.css', '../../style/list-entry.css'] +}) +export class CampaignPlayerDetailComponent { + + campaignPlayer: CampaignPlayer = {campaign: {}, players: []}; + + sumData: any[] = []; + killData: any[] = []; + friendlyFireData: any[] = []; + deathData: any[] = []; + respawnData: any[] = []; + reviveData: any[] = []; + captureData: any[] = []; + + yAxisKill = 'Kills'; + yAxisFriendlyFire = 'FriendlyFire'; + yAxisDeath = 'Tode'; + yAxisRespawn = 'Respawn'; + yAxisRevive = 'Revive'; + yAxisCapture = 'Eroberungen'; + avgLabel = 'Durchschnitt'; + + colorScheme = { + domain: ['#00ce12'] + }; + colorSchemeBar = { + domain: [ + '#2d5a00', '#455600', '#00561f', '#3f3b00', '#003c19', '#083c00' + ] + }; + showRefLines = true; + showRefLabels = true; + killRefLines = []; + deathRefLines = []; + captureRefLines = []; + friendlyFireRefLines = []; + reviveRefLines = []; + respawnRefLines = []; + + gradient = false; + xAxis = true; + yAxis = true; + legend = false; + showXAxisLabel = true; + showYAxisLabel = true; + autoscale = false; + timeline = false; + roundDomains = true; + + totalKills; + totalFriendlyFire; + totalDeath; + totalRespawn; + totalRevive; + totalCapture; + + kdRatio = 0; + maxKd = 1.7; + + respawnDeathRatio = 0; + maxRespawnDeathRatio = 1; + + + constructor(private route: ActivatedRoute, + private location: Location, + private playerService: PlayerService) { + } + + ngOnInit() { + this.route.params + .map(params => [params['id'], params['playerName']]) + .flatMap(id => this.playerService.getCampaignPlayer(id[0], encodeURIComponent(id[1]))) + .subscribe(campaignPlayer => { + this.campaignPlayer = campaignPlayer; + this.killData = this.assignData(this.yAxisKill, "kill"); + this.friendlyFireData = this.assignData(this.yAxisFriendlyFire, "friendlyFire"); + this.deathData = this.assignData(this.yAxisDeath, "death"); + this.respawnData = this.assignData(this.yAxisRespawn, "respawn"); + this.reviveData = this.assignData(this.yAxisRevive, "revive"); + this.captureData = this.assignData(this.yAxisCapture, "flagTouch"); + + this.kdRatio = parseFloat((this.totalKills / this.totalDeath).toFixed(2)); + if (this.kdRatio > 1) this.maxKd = this.kdRatio * 1.7; + + this.respawnDeathRatio = parseFloat((this.totalRespawn / this.totalDeath).toFixed(2)); + + this.sumData = [ + { + name: this.yAxisKill, + value: this.totalKills + }, + { + name: this.yAxisFriendlyFire, + value: this.totalFriendlyFire + }, + { + name: this.yAxisDeath, + value: this.totalDeath + }, + { + name: this.yAxisRespawn, + value: this.totalRespawn + }, + { + name: this.yAxisRevive, + value: this.totalRevive + }, + { + name: this.yAxisCapture, + value: this.totalCapture + } + ]; + + Object.assign(this, [this.sumData, this.killData, this.friendlyFireData, this.deathData, this.respawnData, this.reviveData, this.captureData]); + }); + } + + private assignData(label, field) { + let killObj = { + name: label, + series: [] + }; + const playerLength = this.campaignPlayer.players.length; + let total = 0; + for (let i = 0; i < playerLength; i++) { + const warDateString = ChartUtils.getShortDateString(this.campaignPlayer.players[i].warId.date); + const warKills = this.campaignPlayer.players[i][field]; + killObj.series.push({ + name: warDateString, + value: warKills + }); + total += warKills; + } + switch (field) { + case 'kill': + this.killRefLines.push({value: total / playerLength, name: this.avgLabel}); + this.totalKills = total; + break; + case 'friendlyFire': + this.friendlyFireRefLines.push({value: total / playerLength, name: this.avgLabel}); + this.totalFriendlyFire = total; + break; + case 'death': + this.deathRefLines.push({value: total / playerLength, name: this.avgLabel}); + this.totalDeath = total; + break; + case 'respawn': + this.respawnRefLines.push({value: total / playerLength, name: this.avgLabel}); + this.totalRespawn = total; + break; + case 'revive': + this.reviveRefLines.push({value: total / playerLength, name: this.avgLabel}); + this.totalRevive = total; + break; + case 'flagTouch': + this.captureRefLines.push({value: total / playerLength, name: this.avgLabel}); + this.totalCapture = total; + break; + } + return [killObj]; + } + + navigateBack() { + this.location.back(); + } + +} diff --git a/static/src/app/statistic/overview/stats-overview.component.html b/static/src/app/statistic/overview/stats-overview.component.html index cff395c..7ab2b59 100644 --- a/static/src/app/statistic/overview/stats-overview.component.html +++ b/static/src/app/statistic/overview/stats-overview.component.html @@ -10,9 +10,7 @@ (click)="goToSlide(1)">Spielerzahlen - - - +

Gesamtpunktzahl

diff --git a/static/src/app/statistic/overview/stats-overview.component.ts b/static/src/app/statistic/overview/stats-overview.component.ts index 2b624aa..0a01419 100644 --- a/static/src/app/statistic/overview/stats-overview.component.ts +++ b/static/src/app/statistic/overview/stats-overview.component.ts @@ -2,12 +2,13 @@ import {Component} from "@angular/core"; import {ActivatedRoute} from "@angular/router"; import {CarouselConfig} from "ngx-bootstrap"; import {CampaignService} from "../../services/campaign-service/campaign.service"; +import {ChartUtils} from "../../utils/chart-utils"; @Component({ selector: 'stats-overview', templateUrl: './stats-overview.component.html', - styleUrls: ['./stats-overview.component.css'], + styleUrls: ['./stats-overview.component.css', '../../style/list-entry.css'], inputs: ['campaigns'], providers: [{provide: CarouselConfig, useValue: {interval: false}}] }) @@ -94,11 +95,7 @@ export class StatisticOverviewComponent { for (let i = wars.length - 1; i >= 0; i--) { let j = wars.length - i - 1; - // const warDateString = new Date(wars[i].date); TODO: use ngx-chart timeline - const isoDate = wars[i].date.slice(0, 10); - const dayDate = parseInt(isoDate.slice(8, 10)) + 1; - const warDateString = (dayDate < 10 ? "0" + dayDate : dayDate) + '.' - + isoDate.slice(5, 7) + '.' + isoDate.slice(2, 4); + const warDateString = ChartUtils.getShortDateString(wars[i].date); pointsObj[0].series.push({ name: warDateString, diff --git a/static/src/app/statistic/stats.module.ts b/static/src/app/statistic/stats.module.ts index 15b9e9d..e2edbc4 100644 --- a/static/src/app/statistic/stats.module.ts +++ b/static/src/app/statistic/stats.module.ts @@ -3,16 +3,17 @@ import {CommonModule} from "@angular/common"; import {SharedModule} from "../shared.module"; import {statsRouterModule, statsRoutingComponents} from "./stats.routing"; import {WarService} from "../services/war-service/war.service"; -import {LineChartModule, PieChartModule} from "@swimlane/ngx-charts"; +import {NgxChartsModule} from "@swimlane/ngx-charts"; import {AccordionModule, CarouselModule} from "ngx-bootstrap"; import {CampaignService} from "../services/campaign-service/campaign.service"; import {NgxDatatableModule} from "@swimlane/ngx-datatable"; +import {PlayerService} from "../services/player-service/player.service"; @NgModule({ declarations: statsRoutingComponents, - imports: [CommonModule, SharedModule, statsRouterModule, LineChartModule, PieChartModule, + imports: [CommonModule, SharedModule, statsRouterModule, NgxChartsModule, AccordionModule.forRoot(), CarouselModule.forRoot(), NgxDatatableModule], - providers: [WarService, CampaignService] + providers: [WarService, CampaignService, PlayerService] }) export class StatsModule { static routes = statsRouterModule; diff --git a/static/src/app/statistic/stats.routing.ts b/static/src/app/statistic/stats.routing.ts index acff442..dbc6951 100644 --- a/static/src/app/statistic/stats.routing.ts +++ b/static/src/app/statistic/stats.routing.ts @@ -7,6 +7,7 @@ import {StatisticOverviewComponent} from "./overview/stats-overview.component"; import {WarItemComponent} from "./war-list/war-item.component"; import {ModuleWithProviders} from "@angular/core"; import {CampaignSubmitComponent} from "./campaign-submit/campaign-submit.component"; +import {CampaignPlayerDetailComponent} from "./campaign-player-detail/campaign-player-detail.component"; export const statsRoutes: Routes = [{ @@ -37,10 +38,15 @@ export const statsRoutes: Routes = [{ path: 'war/:id', component: WarDetailComponent, outlet: 'right' - }]; + }, + { + path: 'campaign-player/:id/:playerName', + component: CampaignPlayerDetailComponent, + outlet: 'right' + },]; export const statsRouterModule: ModuleWithProviders = RouterModule.forChild(statsRoutes); export const statsRoutingComponents = [StatisticComponent, StatisticOverviewComponent, CampaignSubmitComponent, - WarListComponent, WarSubmitComponent, WarDetailComponent, WarItemComponent]; + WarListComponent, WarSubmitComponent, WarDetailComponent, CampaignPlayerDetailComponent, WarItemComponent]; diff --git a/static/src/app/statistic/war-detail/war-detail.component.css b/static/src/app/statistic/war-detail/war-detail.component.css index ac08fbe..454c430 100644 --- a/static/src/app/statistic/war-detail/war-detail.component.css +++ b/static/src/app/statistic/war-detail/war-detail.component.css @@ -51,6 +51,7 @@ :host /deep/ .datatable-body-row { color: #222222; border-bottom: 1px solid grey; + cursor: pointer; } :host /deep/ .datatable-body-row:hover { diff --git a/static/src/app/statistic/war-detail/war-detail.component.html b/static/src/app/statistic/war-detail/war-detail.component.html index 6ff3d83..fa69568 100644 --- a/static/src/app/statistic/war-detail/war-detail.component.html +++ b/static/src/app/statistic/war-detail/war-detail.component.html @@ -1,4 +1,4 @@ -
+

{{war.title}} - vom {{war.date | date: 'dd.MM.yyyy'}}

@@ -57,10 +57,13 @@ [messages]="{emptyMessage: 'Loading...'}" [headerHeight]="cellHeight" [rowHeight]="cellHeight" - [cssClasses]='customClasses'> + [cssClasses]='customClasses' + [selectionType]="'single'" + (select)="selectPlayerDetail($event)"> - + {{value}} diff --git a/static/src/app/statistic/war-detail/war-detail.component.ts b/static/src/app/statistic/war-detail/war-detail.component.ts index 4d84ef6..3695170 100644 --- a/static/src/app/statistic/war-detail/war-detail.component.ts +++ b/static/src/app/statistic/war-detail/war-detail.component.ts @@ -1,5 +1,5 @@ import {Component} from "@angular/core"; -import {ActivatedRoute} from "@angular/router"; +import {ActivatedRoute, Router} from "@angular/router"; import {WarService} from "../../services/war-service/war.service"; import {War} from "../../models/model-interfaces"; @@ -7,7 +7,7 @@ import {War} from "../../models/model-interfaces"; @Component({ selector: 'war-detail', templateUrl: './war-detail.component.html', - styleUrls: ['./war-detail.component.css'] + styleUrls: ['./war-detail.component.css', '../../style/list-entry.css'] }) export class WarDetailComponent { @@ -29,6 +29,7 @@ export class WarDetailComponent { }; constructor(private route: ActivatedRoute, + private router: Router, private warService: WarService) { Object.assign(this, this.playerChart) } @@ -64,4 +65,11 @@ export class WarDetailComponent { } } + selectPlayerDetail(player) { + if (player && player.selected && player.selected.length > 0) { + this.router.navigate(['../../campaign-player/' + this.war.campaign + '/' + player.selected[0].name], + {relativeTo: this.route}); + } + } + } diff --git a/static/src/app/utils/chart-utils.ts b/static/src/app/utils/chart-utils.ts new file mode 100644 index 0000000..0761dca --- /dev/null +++ b/static/src/app/utils/chart-utils.ts @@ -0,0 +1,10 @@ +export class ChartUtils { + + public static getShortDateString(date) : string { + const isoDate = date.slice(0, 10); + const dayDate = parseInt(isoDate.slice(8, 10)) + 1; + return (dayDate < 10 ? "0" + dayDate : dayDate) + '.' + + isoDate.slice(5, 7) + '.' + isoDate.slice(2, 4); + } + +}