Created
September 14, 2019 16:40
-
-
Save alhankeser/bfe3ca340e1589a3088719bf4a870fc2 to your computer and use it in GitHub Desktop.
Zwift Report live race view
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div class="container-fluid"> | |
<navigation :race="race"/> | |
<transition name="fade"> | |
<div v-if="loading" style="position:fixed" class="animated yt-loader"></div> | |
</transition> | |
<div class="row content"> | |
<div class="race-header"> | |
<div class="relative-start"> | |
<span class="badge live-alert" style="font-style:normal;" v-if="isLive(race)">LIVE!</span> | |
{{ isPast(race) ? 'Started' : 'Starts' }} | |
{{ race.eventStart | momentRelativeTime }}: | |
{{ race.eventStart | momentDate }} | |
{{ race.eventStart | momentTime }} | |
{{ defaultTimeZone }} | |
<a :href="'https://www.zwiftpower.com/events.php?zid=' + race.id" target="_blank">Final Results on ZP</a> | |
</div> | |
<div style="background: #ffff; padding: 1rem;" v-if="showHelp"><strong>Notes:</strong> This is not an official Zwift app. Gaps/groups may be wrong for brief moments. If you don't see yourself or a rider, it's because they're either finished with the race or the Zwift API is not being co-operative. Red, green, yellow, orange, purple designate categories. Grey indicates no HR and/or zPower riders. <strong>Pro Tips:</strong> Click/tap a rider name to highlight for easier following. Zoom out with your browser to see more of the race.<br><a href="javascript:void(0);" @click="hideHelp()">Close</a></div> | |
<!-- <div class="search-box"> | |
<input @keyup="filterRiders" v-model="nameFilter" type="text"> | |
</div> --> | |
</div> | |
<div style="font-size:2rem; padding: 2rem;" v-if="!riders || riders.length < 1"> | |
<div v-if="false"> | |
<p>This event has ended. See <a :href="'https://www.zwiftpower.com/events.php?zid=' + race.id" target="_blank">final results on ZP</a></p> | |
<router-link class="btn btn-primary" :to="'/'">See Live Races</router-link> | |
</div> | |
<div v-else> | |
<p>The event is currently not live, data is still loading, or something is broken.</p> | |
<strong>This page will refresh itself if results start to come in.</strong> | |
</div> | |
</div> | |
<div class="packs"> | |
<transition-group name="flip-list"> | |
<div class="pack" v-for="pack in riders" :key="pack[0].pack_id"> | |
<div class="rider-count f-black">{{pack.length > 1 ? pack.length + ' Riders' : 'Solo'}}</div> | |
<div class="pack-data"> | |
<div><small>{{ getPackDistance(pack) }}</small> | |
<small>{{ getPackPower(pack) }}<span v-if="false" class="red-text">{{ '+' + decimalToTimeGap(pack[0].timeGap) }}</span></small></div> | |
<div v-if="getPackGap(pack)" class="red-text gap" v-html="'+' + decimalToTimeGap(pack[0].timeGap) + ' <small style=\'font-weight\:initial\'>(+' + decimalToTimeGap(pack[0].pack_time_gap_to_prev) + ')</small>'"></div> | |
<div v-if="false" v-html="decimalToTimeGap(pack[0].pack_time_gap_to_prev)"></div> | |
</div> | |
<div class="pack-container"> | |
<div class="rider" v-for="rider in pack" @click="star(rider)" :class="getRiderClass(rider)" :style="pack.length > 20 ? 'padding: 0.4rem 0.2rem;' : '' " :key="rider.id"> | |
<div class="name" v-html="rider.lastName"></div> | |
<i class="fas fa-heart"></i> | |
<i class="fas fa-motorcycle"></i> | |
<!-- <div class="rider-data" v-html="rider.progress/1000"></div> --> | |
</div> | |
</div> | |
</div> | |
</transition-group> | |
</div> | |
</div> | |
<div id="scrollController"> | |
<a class="btn btn-default" href="javascript:void(0)" @click="toggleScroller()">Auto Scroll Left-Right</a> | |
</div> | |
</div> | |
</template> | |
<script> | |
const zrLog = function(data) { | |
true ? console.log(data) : false; | |
} | |
const firebase = require("firebase"); | |
require("firebase/firestore"); | |
var config = { | |
apiKey: "XXX", | |
authDomain: "XXX.firebaseapp.com", | |
projectId: 'XXX'` | |
}; | |
firebase.initializeApp(config); | |
var db = firebase.firestore(); | |
var scroller; | |
import Navigation from './Navigation' | |
import axios from 'axios' | |
import moment from 'moment' | |
import momentDurationFormatSetup from 'moment-duration-format' | |
import { setTimeout } from 'timers'; | |
momentDurationFormatSetup(moment) | |
var storageKey = function(key) { | |
return '__zwiftReport_' + window.location.hash.split('/').pop(1) + '_' + key | |
} | |
var starredStorageKey = '__zwiftReport_starred'; | |
var starred = JSON.parse(localStorage.getItem(starredStorageKey)) || []; | |
export default { | |
name: 'Race', | |
components: { | |
navigation: Navigation | |
}, | |
data() { | |
return { | |
now: moment(), | |
riders: false, | |
nameFilter: '', | |
starred: starred, | |
race: {}, | |
loading: true, | |
showHelp: localStorage.getItem('__zr_help') === 'hide' ? false : true, | |
defaultTimeZone: String(moment()._d).split(' ').pop(1), | |
broadcast: window.location.hash.split('?')[1] ? true : false, | |
isScrolling: false, | |
scrollDirection: 'right' | |
} | |
}, | |
methods: { | |
hideHelp() { | |
this.showHelp = false | |
localStorage.setItem('__zr_help', 'hide'); | |
}, | |
filterRiders () { | |
for (var pack in this.riders) { | |
zrLog(this.riders[pack]) | |
this.riders[pack] = this.riders[pack].filter(rider => { | |
zrLog(rider.lastName, rider.lastName.toLowerCase().search(this.nameFilter.toLowerCase()) > -1) | |
return rider.lastName.toLowerCase().search(this.nameFilter.toLowerCase()) > -1 | |
}) | |
} | |
}, | |
getRiderClass (rider) { | |
return { | |
'cat-a': rider.categoryLabel === 1, | |
'cat-b': rider.categoryLabel === 2, | |
'cat-c': rider.categoryLabel === 3, | |
'cat-d': rider.categoryLabel === 4, | |
'cat-e': rider.categoryLabel === 5, | |
// 'orange': rider.heartrate > 140, | |
// 'red' : rider.heartrate > 169, | |
// 'boom': rider.heartrate > 169, //(220 - rider.age) * 0.9, | |
'nohr': rider.heartrate === 0, | |
'zpower': rider.powerSourceModel === 'zPower', | |
'faded' : rider.heartrate === 0 || rider.powerSourceModel === 'zPower', | |
'starred': this.isStarred(rider) | |
} | |
}, | |
refreshData () { | |
zrLog('refreshData') | |
var docRef = db.collection('races').doc(this.$route.params.id) | |
var $this = this; | |
docRef.get().then(function(doc) { | |
if (doc.exists) { | |
zrLog('Doc exists') | |
$this.updateRiders($this.organizeRiders(doc.data())) | |
} | |
else { | |
zrLog('Doc does not exist') | |
$this.getRiders($this.$route.params.id) | |
} | |
}) | |
$this.getRiders($this.$route.params.id) | |
}, | |
updateRiders (data) { | |
zrLog('updateRiders') | |
this.riders = data | |
}, | |
load () { | |
var $this = this | |
this.loading = true | |
setTimeout(function() { | |
$this.loading = false | |
}, 3000) | |
}, | |
storeRiders (data) { | |
if (data !== [] && Object.keys(data).length > 0 && typeof(data) === 'object') { | |
// var timestamp = this.getTimestamp(); | |
// var dataObject = {} | |
// dataObject[timestamp] = {time: timestamp, riders: data} | |
db.collection('races').doc(this.$route.params.id).set(data) | |
.catch(function(error) { | |
console.error("Error adding document: ", error) | |
}); | |
zrLog('storeRiders') | |
} | |
else { | |
this.updateRiders(false) | |
zrLog('Tried storeRiders but failed') | |
} | |
}, | |
organizeRiders (riders) { | |
zrLog('organizeRiders') | |
for (var pack in riders) { | |
var notStarredRiders = [] | |
var starredRiders = [] | |
riders[pack].forEach(rider => { | |
this.isStarred(rider) ? starredRiders.push(rider) : notStarredRiders.push(rider) | |
}) | |
riders[pack] = starredRiders.concat(notStarredRiders) | |
} | |
return riders | |
}, | |
getRiders (id) { | |
var $this = this | |
var riders | |
zrLog('getRiders called') | |
axios.get('/api/races/' + id + '/live',{'timeout': 360000 }).then(response => { | |
zrLog('getRiders got api response') | |
riders = response['data'] | |
riders = this.organizeRiders(riders) | |
this.updateRiders(riders) | |
this.storeRiders(riders) | |
}) | |
}, | |
getRace (id) { | |
var $this = this; | |
zrLog('getRace called') | |
axios.get('/api/races/' + id).then(response => { | |
$this.race = response['data']; | |
}); | |
this.$route.params['raceName'] = this.race.name | |
}, | |
// | |
getMoment (toMoment) { | |
return moment(toMoment) | |
}, | |
// | |
getTimestamp () { | |
var date = new Date | |
return date.getTime() - date.getTimezoneOffset() | |
}, | |
unixToMoment (unixDate) { | |
return moment.unix(unixDate) | |
}, | |
timeDifference (race) { | |
return moment(race.eventStart).diff(this.now) | |
}, | |
updateNow () { | |
this.now = moment() | |
setTimeout(this.updateNow, 1000) | |
}, | |
isPast (race) { | |
return this.timeDifference(race) < 0 | |
}, | |
isLive (race) { | |
return this.isPast(race) === 0 // && Number(race.fin) | |
}, | |
calculateGap (gapInSeconds) { | |
if (Number(gapInSeconds) === 0) { | |
return 'At the front' | |
} | |
var duration = moment.duration(gapInSeconds, 'seconds'); | |
var formatted = duration.format("\+ h [hrs], m [min], s [sec]"); | |
return formatted; | |
}, | |
roundTo (number, precision) { | |
var factor = Math.pow(10, precision); | |
return Math.round(number * factor) / factor; | |
}, | |
getPackGap (pack) { | |
var gap = pack[0].pack_gap | |
if (gap > 0) { | |
if (gap > 1000) { | |
return '+' + this.roundTo(gap/1000, 2) + 'km' | |
} | |
else { | |
return '+' + this.roundTo(gap, 1) + 'm' | |
} | |
} | |
else { | |
return false | |
} | |
}, | |
isStarred (rider) { | |
return this.starred.indexOf(rider.id) > -1 | |
}, | |
star (rider) { | |
if (this.isStarred(rider)) { | |
this.starred.splice(this.starred.indexOf(rider.id), 1) | |
} | |
else { | |
this.starred.push(rider.id) | |
} | |
localStorage.setItem(starredStorageKey, JSON.stringify(this.starred)) | |
}, | |
getPackPower(pack) { | |
return this.roundTo(pack[0].power/(pack[0].weight/1000),2) + 'w/kg' | |
}, | |
getPackDistance(pack) { | |
return this.roundTo(pack[0].distance/1000, 2) + 'km' | |
}, | |
getDistance(distance) { | |
return this.roundTo(distance/1000, 2) + 'km' | |
}, | |
decimalToTimeGap(gap) { | |
return gap < 1440 ? moment.duration(gap, "minutes").format() : '∞' | |
}, | |
toggleScroller() { | |
var $this = this; | |
if (!this.isScrolling) { | |
scroller = setInterval(function() { | |
$this.scroll() | |
}, 5000) | |
this.isScrolling = true; | |
} | |
else { | |
window.clearInterval(scroller); | |
this.isScrolling = false; | |
} | |
}, | |
scroll() { | |
console.log(this.remainingToScroll()) | |
if (this.scrollDirection === 'right') { | |
this.remainingToScroll() > 0 ? window.scrollBy(window.innerWidth, 0) : this.scrollDirection = 'left' | |
} | |
if (this.scrollDirection === 'left') { | |
this.remainingToScroll() > 0 ? window.scrollBy(-10, 0) : this.scrollDirection = 'right' | |
} | |
}, | |
remainingToScroll() { | |
if (this.scrollDirection === 'right') { | |
return document.body["scrollWidth"] - window.scrollX - window.innerWidth | |
} | |
if (this.scrollDirection === 'left') { | |
return window.scrollX | |
} | |
} | |
}, | |
filters: { | |
momentDate: function (date) { | |
return moment(date).format('MMM Do') | |
}, | |
momentTime: function (date) { | |
return moment(date).format('h:mm a') | |
}, | |
momentRelativeTime: function (date) { | |
return moment(date).fromNow() | |
} | |
}, | |
mounted() { | |
var $this = this; | |
if (this.$route.name === 'Race') { | |
this.getRace(this.$route.params.id) | |
$this.getRiders($this.$route.params.id) | |
this.refreshData() | |
} | |
var refreshResults; | |
var resetInterval = function(ms) { | |
zrLog('resetInterval interval set at ' + ms) | |
refreshResults = setInterval(function() { | |
zrLog('refreshResults called') | |
if ($this.$route.name === 'Race' && | |
Number($this.$route.params.id) === Number(window.location.hash.split('/').pop(1)) ) { | |
zrLog('refreshResults invoking getRiders') | |
$this.getRiders($this.$route.params.id) | |
} | |
else { | |
zrLog('refreshResults interval cleared') | |
clearInterval(refreshResults) | |
} | |
}, ms) | |
} | |
resetInterval(20000); | |
db.collection('races').doc(this.$route.params.id).onSnapshot(function(doc) { | |
zrLog('db listener called') | |
$this.load() | |
if (!doc.metadata.hasPendingWrites) { | |
// var dbData = db.collection('races/'+$this.$route.params.id).limit(1) | |
// zrLog(dbData.data()); | |
var riders = $this.organizeRiders(doc.data()) | |
$this.updateRiders(riders) | |
zrLog('this.riders updated from db') | |
clearInterval(refreshResults) | |
zrLog('refreshResults cleared and reset') | |
resetInterval(20000) | |
} | |
}); | |
this.updateNow() | |
// ga('set', 'page', '/races/' + this.$route.params.id); | |
// ga('send', 'pageview'); | |
} | |
} | |
</script> | |
<style> | |
.flip-list-move { | |
transition: transform 1s !important; | |
} | |
.fade-enter-active, .fade-leave-active { | |
transition: opacity .5s; | |
} | |
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { | |
opacity: 0; | |
} | |
.packs > span{ | |
display: flex; | |
flex-flow: column wrap; | |
max-height: 80vh; | |
justify-content: flex-start; | |
width: min-content; | |
padding-left: 2rem; | |
} | |
.pack { | |
display: flex; | |
flex-direction: column; | |
margin: 0.8rem 0.4rem; | |
flex-grow: 0; | |
flex-shrink: 0; | |
width: 120px; | |
background: #fff; | |
box-shadow: 0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.24); | |
transition: all .3s cubic-bezier(.25,.8,.25,1); | |
} | |
.pack-data { | |
flex-shrink: 0; | |
display: flex; | |
flex-wrap: wrap; | |
justify-content: space-between; | |
font-size: 1.4rem; | |
} | |
.pack-data, .rider-count { | |
padding: 0.6rem; | |
} | |
.rider-count { | |
background: #f9f9f9; | |
} | |
.gap, .rider-count { | |
font-weight: 700; | |
font-size: 1.6rem; | |
} | |
.red-text { | |
color: red; | |
} | |
.pack-container > span, .pack-container { | |
display: flex; | |
flex-wrap: wrap; | |
} | |
.race-container, .pack-container { | |
-webkit-transition: all 0.3s cubic-bezier(.25,.8,.25,1); | |
transition: all 0.3s cubic-bezier(.25,.8,.25,1); | |
} | |
.race-container .name { | |
font-size: 3rem; | |
font-weight: 700; | |
line-height: 1; | |
} | |
.bg-success { | |
background-color: #dff0d8 !important; | |
} | |
.live-alert { | |
background-color: #57c44d; | |
} | |
.rider { | |
padding: 0.8rem 0.2rem; | |
/* text-align: center; */ | |
margin: 1px 0 0 0; | |
background: #ccc; | |
color: #000; | |
overflow: hidden; | |
flex-grow: 1; | |
flex-basis: 100%; | |
} | |
.rider:hover { | |
opacity: 0.8; | |
cursor: pointer; | |
box-shadow: 0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.24); | |
} | |
.pack-data > * { | |
line-height: 1.2; | |
} | |
.rider .name { | |
font-size: 1.4rem; | |
line-height: 1; | |
text-transform: capitalize; | |
color: #fff; | |
} | |
.race-header { | |
/* padding: 1rem 2rem; */ | |
} | |
.race-header .name { | |
font-size: 3rem; | |
font-weight: 700; | |
} | |
.utilities .back-link { | |
flex-basis: 18%; | |
} | |
.cat-a, .cat-b, .cat-c, .cat-d, .cat-e { | |
opacity: 0.9; | |
} | |
.orange { | |
background-color: orange; | |
} | |
.cat-a, .red { | |
background-color: #fc4119; | |
} | |
.cat-b, .green { | |
background-color: #58c34e; | |
} | |
.cat-c, .blue { | |
background-color: #3ec0e9; | |
} | |
.cat-d, .yellow { | |
background-color: #fccf0b; | |
} | |
.cat-e { | |
background-color: #943e5e; | |
} | |
.cat-a.faded { | |
background-color: rgba(252, 65, 25, .4); | |
} | |
.cat-b.faded { | |
background-color: rgba(88, 195, 78, .4); | |
} | |
.cat-c.faded { | |
background-color: rgba(62, 192, 233, .4); | |
} | |
.cat-d.faded { | |
background-color: rgba(252, 207, 11, .4); | |
} | |
.cat-e.faded { | |
background-color: rgba(148, 62, 94, .4); | |
} | |
.faded.rider, .faded.rider .name { | |
color: #5d5d5d; | |
} | |
.rider.faded { | |
display: flex; | |
} | |
.rider svg { | |
display: none; | |
height: 12px; | |
width: 12px; | |
} | |
.rider.nohr .fa-heart { | |
display: initial; | |
} | |
.rider.zpower .fa-motorcycle { | |
display: initial; | |
} | |
/* .zpower, .nohr { | |
opacity: .4; | |
} */ | |
.boom { | |
-webkit-animation: blowing-up 1s infinite; /* Safari 4+ */ | |
-moz-animation: blowing-up 1s infinite; /* Fx 5+ */ | |
-o-animation: blowing-up 1s infinite; /* Opera 12+ */ | |
animation: blowing-up 1s infinite; /* IE 10+, Fx 29+ */ | |
} | |
@-webkit-keyframes blowing-up { | |
0%, 49% { | |
opacity: 0.7; | |
} | |
50%, 100% { | |
opacity: 1; | |
} | |
} | |
.rider.starred .name { | |
font-size: 2rem; | |
font-weight: 700; | |
} | |
.rider.starred { | |
border: 6px solid #ce57c4; | |
} | |
#scrollController { | |
position: fixed; | |
bottom: 20px; | |
left: 20px; | |
display: none; | |
} | |
.animated { | |
-webkit-animation-duration: 3s; | |
animation-duration: 3s; | |
-webkit-animation-fill-mode: both; | |
animation-fill-mode: both; | |
-moz-user-select: none; | |
-ms-user-select: none; | |
-webkit-user-select: none; | |
} | |
.yt-loader { | |
-webkit-animation-name: horizontalProgressBar; | |
animation-name: horizontalProgressBar; | |
-webkit-animation-timing-function: ease; | |
animation-timing-function: ease; | |
background: #57c44d; | |
height: 3px; | |
left: 0; | |
position: fixed; | |
top: 0; | |
width: 0%; | |
z-index: 9999; | |
} | |
@keyframes horizontalProgressBar | |
{ | |
0% {width: 0%;} | |
25% {width: 22%;} | |
50% {width: 55%;} | |
75% {width: 83%;} | |
100% {width:100%;} | |
} | |
@-webkit-keyframes horizontalProgressBar /* Safari and Chrome */ | |
{ | |
0% {width: 0%;} | |
25% {width: 22%;} | |
50% {width: 55%;} | |
75% {width: 83%;} | |
100% {width:100%;} | |
} | |
@media only screen and (max-width: 1080px) { | |
} | |
@media only screen and (max-width: 800px) { | |
.race-header .name { | |
line-height: 1.2; | |
} | |
.rider { | |
padding: 1rem 0.2rem; | |
} | |
} | |
@media only screen and (max-height: 200px) { | |
.race-header { | |
display: none; | |
} | |
.pack-data { | |
min-width: 60px; | |
} | |
.rider-count { | |
min-width: 78px; | |
} | |
.pack-data small:last-child { | |
display: none; | |
} | |
.gap { | |
display: none; | |
} | |
.pack { | |
flex-direction: column; | |
width: initial; | |
align-items: flex-start; | |
min-height: 45px; | |
} | |
.pack-container { | |
flex-wrap: nowrap; | |
} | |
.packs > span { | |
width: initial; | |
padding-left: initial; | |
} | |
.rider .name { | |
white-space: nowrap; | |
} | |
.pack > * { | |
min-width: 78px; | |
} | |
.rider.starred .name { | |
font-size: initial; | |
font-weight: initial; | |
} | |
body { | |
background: #000; | |
} | |
#scrollController { | |
display: block; | |
} | |
.relative-start { | |
display: none; | |
} | |
} | |
</style> | |
<style scoped> | |
.relative-start { | |
font-style: italic; | |
color: #737373; | |
position: fixed; | |
bottom: 0; | |
right: 0; | |
background: #fff; | |
padding: .5rem; | |
font-size: 11px; | |
z-index: 10; | |
} | |
</style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment