mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-25 23:12:29 -05:00
472 lines
18 KiB
CoffeeScript
472 lines
18 KiB
CoffeeScript
CocoView = require 'views/core/CocoView'
|
|
Level = require 'models/Level'
|
|
LevelSession = require 'models/LevelSession'
|
|
ModelModal = require 'views/modal/ModelModal'
|
|
User = require 'models/User'
|
|
utils = require 'core/utils'
|
|
|
|
module.exports = class CampaignLevelView extends CocoView
|
|
id: 'campaign-level-view'
|
|
template: require 'templates/editor/campaign/campaign-level-view'
|
|
|
|
events:
|
|
'change .line-graph-checkbox': 'updateGraphCheckbox'
|
|
'click .close': 'onClickClose'
|
|
'click #reload-button': 'onClickReloadButton'
|
|
'dblclick .recent-session': 'onDblClickRecentSession'
|
|
'mouseenter .graph-point': 'onMouseEnterPoint'
|
|
'mouseleave .graph-point': 'onMouseLeavePoint'
|
|
'click .replay-button': 'onClickReplay'
|
|
|
|
constructor: (options, @level) ->
|
|
super(options)
|
|
@fullLevel = new Level _id: @level.id
|
|
@fullLevel.fetch()
|
|
@listenToOnce @fullLevel, 'sync', => @render?()
|
|
@levelSlug = @level.get('slug')
|
|
@getAnalytics()
|
|
|
|
getRenderData: ->
|
|
c = super()
|
|
c.level = if @fullLevel.loaded then @fullLevel else @level
|
|
c.analytics = @analytics
|
|
c
|
|
|
|
afterRender: ->
|
|
super()
|
|
$("#input-startday").datepicker dateFormat: "yy-mm-dd"
|
|
$("#input-endday").datepicker dateFormat: "yy-mm-dd"
|
|
# TODO: Why does this have to be called from afterRender() instead of getRenderData()?
|
|
@updateAnalyticsGraphs()
|
|
|
|
updateGraphCheckbox: (e) ->
|
|
lineID = $(e.target).data('lineid')
|
|
checked = $(e.target).prop('checked')
|
|
for graph in @analytics.graphs
|
|
for line in graph.lines
|
|
if line.lineID is lineID
|
|
line.enabled = checked
|
|
return @render()
|
|
|
|
onClickClose: ->
|
|
@$el.addClass('hidden')
|
|
@trigger 'hidden'
|
|
|
|
onClickReloadButton: () =>
|
|
startDay = $('#input-startday').val()
|
|
endDay = $('#input-endday').val()
|
|
@getAnalytics startDay, endDay
|
|
|
|
onDblClickRecentSession: (e) ->
|
|
# Admin view of players' code
|
|
return unless me.isAdmin()
|
|
row = $(e.target).parent()
|
|
player = new User _id: row.data 'player-id'
|
|
session = new LevelSession _id: row.data 'session-id'
|
|
@openModalView new ModelModal models: [session, player]
|
|
|
|
onMouseEnterPoint: (e) ->
|
|
pointID = $(e.target).data('pointid')
|
|
container = @$el.find(".graph-point-info-container[data-pointid=#{pointID}]").show()
|
|
margin = 20
|
|
width = container.outerWidth()
|
|
height = container.outerHeight()
|
|
container.css('left', e.offsetX - width / 2)
|
|
container.css('top', e.offsetY - height - margin)
|
|
|
|
onMouseLeavePoint: (e) ->
|
|
pointID = $(e.target).data('pointid')
|
|
@$el.find(".graph-point-info-container[data-pointid=#{pointID}]").hide()
|
|
|
|
onClickReplay: (e) ->
|
|
sessionID = $(e.target).closest('tr').data 'session-id'
|
|
url = "/play/level/#{@level.get('slug')}?session=#{sessionID}&observing=true"
|
|
window.open url, '_blank'
|
|
|
|
updateAnalyticsGraphData: ->
|
|
# console.log 'updateAnalyticsGraphData'
|
|
# Build graphs based on available @analytics data
|
|
# Currently only one graph
|
|
@analytics.graphs = [graphID: 'level-completions', lines: []]
|
|
|
|
# TODO: Where should this metadata live?
|
|
# TODO: lineIDs assumed to be unique across graphs
|
|
completionLineID = 'level-completions'
|
|
playtimeLineID = 'level-playtime'
|
|
helpsLineID = 'helps-clicked'
|
|
videosLineID = 'help-videos'
|
|
lineMetadata = {}
|
|
lineMetadata[completionLineID] =
|
|
description: 'Level Completion (%)'
|
|
color: 'red'
|
|
lineMetadata[playtimeLineID] =
|
|
description: 'Average Playtime (s)'
|
|
color: 'green'
|
|
lineMetadata[helpsLineID] =
|
|
description: 'Help click rate (%)'
|
|
color: 'blue'
|
|
lineMetadata[videosLineID] =
|
|
description: 'Help video rate (%)'
|
|
color: 'purple'
|
|
|
|
# Use this days aggregate to fill in missing days from the analytics data
|
|
days = {}
|
|
days["#{day.created[0..3]}-#{day.created[4..5]}-#{day.created[6..7]}"] = true for day in @analytics.levelCompletions.data if @analytics?.levelCompletions?.data?
|
|
days[day.created] = true for day in @analytics.levelPlaytimes.data if @analytics?.levelPlaytimes?.data?
|
|
days["#{day.day[0..3]}-#{day.day[4..5]}-#{day.day[6..7]}"] = true for day in @analytics.levelHelps.data if @analytics?.levelHelps?.data?
|
|
days = Object.keys(days).sort (a, b) -> if a < b then -1 else 1
|
|
if days.length > 0
|
|
currentIndex = 0
|
|
currentDay = days[currentIndex]
|
|
currentDate = new Date(currentDay + "T00:00:00.000Z")
|
|
lastDay = days[days.length - 1]
|
|
while currentDay isnt lastDay
|
|
days.splice currentIndex, 0, currentDay if days[currentIndex] isnt currentDay
|
|
currentIndex++
|
|
currentDate.setUTCDate(currentDate.getUTCDate() + 1)
|
|
currentDay = currentDate.toISOString().substr(0, 10)
|
|
|
|
# Update level completion graph data
|
|
dayStartedMap = {}
|
|
if @analytics?.levelCompletions?.data?.length > 0
|
|
# Build line data
|
|
levelPoints = []
|
|
for day, i in @analytics.levelCompletions.data
|
|
dayStartedMap[day.created] = day.started
|
|
rate = parseFloat(day.rate)
|
|
levelPoints.push
|
|
x: i
|
|
y: rate
|
|
started: day.started
|
|
day: "#{day.created[0..3]}-#{day.created[4..5]}-#{day.created[6..7]}"
|
|
pointID: "#{completionLineID}#{i}"
|
|
values: ["Started: #{day.started}", "Finished: #{day.finished}", "Completion rate: #{rate.toFixed(2)}%"]
|
|
# Ensure points for each day
|
|
for day, i in days
|
|
if levelPoints.length <= i or levelPoints[i].day isnt day
|
|
levelPoints.splice i, 0,
|
|
y: 0.0
|
|
day: day
|
|
values: []
|
|
levelPoints[i].x = i
|
|
levelPoints[i].pointID = "#{completionLineID}#{i}"
|
|
@analytics.graphs[0].lines.push
|
|
lineID: completionLineID
|
|
enabled: true
|
|
points: levelPoints
|
|
description: lineMetadata[completionLineID].description
|
|
lineColor: lineMetadata[completionLineID].color
|
|
min: 0
|
|
max: 100.0
|
|
|
|
# Update average playtime graph data
|
|
if @analytics?.levelPlaytimes?.data?.length > 0
|
|
# Build line data
|
|
playtimePoints = []
|
|
for day, i in @analytics.levelPlaytimes.data
|
|
avg = parseFloat(day.average)
|
|
playtimePoints.push
|
|
x: i
|
|
y: avg
|
|
day: day.created
|
|
pointID: "#{playtimeLineID}#{i}"
|
|
values: ["Average playtime: #{avg.toFixed(2)}s"]
|
|
# Ensure points for each day
|
|
for day, i in days
|
|
if playtimePoints.length <= i or playtimePoints[i].day isnt day
|
|
playtimePoints.splice i, 0,
|
|
y: 0.0
|
|
day: day
|
|
values: []
|
|
playtimePoints[i].x = i
|
|
playtimePoints[i].pointID = "#{playtimeLineID}#{i}"
|
|
@analytics.graphs[0].lines.push
|
|
lineID: playtimeLineID
|
|
enabled: true
|
|
points: playtimePoints
|
|
description: lineMetadata[playtimeLineID].description
|
|
lineColor: lineMetadata[playtimeLineID].color
|
|
min: 0
|
|
max: d3.max(playtimePoints, (d) -> d.y)
|
|
|
|
# Update help graph data
|
|
if @analytics?.levelHelps?.data?.length > 0
|
|
# Build line data
|
|
helpPoints = []
|
|
videoPoints = []
|
|
for day, i in @analytics.levelHelps.data
|
|
helpCount = day.alertHelps + day.paletteHelps
|
|
started = dayStartedMap[day.day] ? 0
|
|
clickRate = if started > 0 then helpCount / started * 100 else 0
|
|
videoRate = day.videoStarts / helpCount * 100
|
|
helpPoints.push
|
|
x: i
|
|
y: clickRate
|
|
day: "#{day.day[0..3]}-#{day.day[4..5]}-#{day.day[6..7]}"
|
|
pointID: "#{helpsLineID}#{i}"
|
|
values: ["Helps clicked: #{helpCount}", "Helps click clickRate: #{clickRate.toFixed(2)}%"]
|
|
videoPoints.push
|
|
x: i
|
|
y: videoRate
|
|
day: "#{day.day[0..3]}-#{day.day[4..5]}-#{day.day[6..7]}"
|
|
pointID: "#{videosLineID}#{i}"
|
|
values: ["Help videos started: #{day.videoStarts}", "Help videos start rate: #{videoRate.toFixed(2)}%"]
|
|
# Ensure points for each day
|
|
for day, i in days
|
|
if helpPoints.length <= i or helpPoints[i].day isnt day
|
|
helpPoints.splice i, 0,
|
|
y: 0.0
|
|
day: day
|
|
values: []
|
|
helpPoints[i].x = i
|
|
helpPoints[i].pointID = "#{helpsLineID}#{i}"
|
|
if videoPoints.length <= i or videoPoints[i].day isnt day
|
|
videoPoints.splice i, 0,
|
|
y: 0.0
|
|
day: day
|
|
values: []
|
|
videoPoints[i].x = i
|
|
videoPoints[i].pointID = "#{videosLineID}#{i}"
|
|
if d3.max(helpPoints, (d) -> d.y) > 0
|
|
@analytics.graphs[0].lines.push
|
|
lineID: helpsLineID
|
|
enabled: true
|
|
points: helpPoints
|
|
description: lineMetadata[helpsLineID].description
|
|
lineColor: lineMetadata[helpsLineID].color
|
|
min: 0
|
|
max: 100.0
|
|
if d3.max(videoPoints, (d) -> d.y) > 0
|
|
@analytics.graphs[0].lines.push
|
|
lineID: videosLineID
|
|
enabled: true
|
|
points: videoPoints
|
|
description: lineMetadata[videosLineID].description
|
|
lineColor: lineMetadata[videosLineID].color
|
|
min: 0
|
|
max: 100.0
|
|
|
|
updateAnalyticsGraphs: ->
|
|
# Build d3 graphs
|
|
return unless @analytics?.graphs?.length > 0
|
|
containerSelector = '.line-graph-container'
|
|
# console.log 'updateAnalyticsGraphs', containerSelector, @analytics.graphs
|
|
|
|
margin = 20
|
|
keyHeight = 20
|
|
xAxisHeight = 20
|
|
yAxisWidth = 40
|
|
containerWidth = $(containerSelector).width()
|
|
containerHeight = $(containerSelector).height()
|
|
|
|
for graph in @analytics.graphs
|
|
graphLineCount = _.reduce graph.lines, ((sum, item) -> if item.enabled then sum + 1 else sum), 0
|
|
svg = d3.select(containerSelector).append("svg")
|
|
.attr("width", containerWidth)
|
|
.attr("height", containerHeight)
|
|
width = containerWidth - margin * 2 - yAxisWidth * graphLineCount
|
|
height = containerHeight - margin * 2 - xAxisHeight - keyHeight * graphLineCount
|
|
currentLine = 0
|
|
for line in graph.lines
|
|
continue unless line.enabled
|
|
xRange = d3.scale.linear().range([0, width]).domain([d3.min(line.points, (d) -> d.x), d3.max(line.points, (d) -> d.x)])
|
|
yRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max])
|
|
|
|
# x-Axis and guideline once
|
|
if currentLine is 0
|
|
startDay = new Date(line.points[0].day)
|
|
endDay = new Date(line.points[line.points.length - 1].day)
|
|
xAxisRange = d3.time.scale()
|
|
.domain([startDay, endDay])
|
|
.range([0, width])
|
|
xAxis = d3.svg.axis()
|
|
.scale(xAxisRange)
|
|
svg.append("g")
|
|
.attr("class", "x axis")
|
|
.call(xAxis)
|
|
.selectAll("text")
|
|
.attr("dy", ".35em")
|
|
.attr("transform", "translate(" + (margin + yAxisWidth * (graphLineCount - 1)) + "," + (height + margin) + ")")
|
|
.style("text-anchor", "start")
|
|
|
|
# Horizontal guidelines
|
|
svg.selectAll(".line")
|
|
.data([10, 30, 50, 70, 90])
|
|
.enter()
|
|
.append("line")
|
|
.attr("x1", margin + yAxisWidth * graphLineCount)
|
|
.attr("y1", (d) -> margin + yRange(d))
|
|
.attr("x2", margin + yAxisWidth * graphLineCount + width)
|
|
.attr("y2", (d) -> margin + yRange(d))
|
|
.attr("stroke", line.lineColor)
|
|
.style("opacity", "0.5")
|
|
|
|
# y-Axis
|
|
yAxisRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max])
|
|
yAxis = d3.svg.axis()
|
|
.scale(yRange)
|
|
.orient("left")
|
|
svg.append("g")
|
|
.attr("class", "y axis")
|
|
.attr("transform", "translate(" + (margin + yAxisWidth * currentLine) + "," + margin + ")")
|
|
.style("color", line.lineColor)
|
|
.call(yAxis)
|
|
.selectAll("text")
|
|
.attr("y", 0)
|
|
.attr("x", 0)
|
|
.attr("fill", line.lineColor)
|
|
.style("text-anchor", "start")
|
|
|
|
# Key
|
|
svg.append("line")
|
|
.attr("x1", margin)
|
|
.attr("y1", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2)
|
|
.attr("x2", margin + 40)
|
|
.attr("y2", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2)
|
|
.attr("stroke", line.lineColor)
|
|
.attr("class", "key-line")
|
|
svg.append("text")
|
|
.attr("x", margin + 40 + 10)
|
|
.attr("y", margin + height + xAxisHeight + keyHeight * currentLine + (keyHeight + 10) / 2)
|
|
.attr("fill", line.lineColor)
|
|
.attr("class", "key-text")
|
|
.text(line.description)
|
|
|
|
# Path and points
|
|
svg.selectAll(".circle")
|
|
.data(line.points)
|
|
.enter()
|
|
.append("circle")
|
|
.attr("transform", "translate(" + (margin + yAxisWidth * graphLineCount) + "," + margin + ")")
|
|
.attr("cx", (d) -> xRange(d.x))
|
|
.attr("cy", (d) -> yRange(d.y))
|
|
.attr("r", (d) -> if d.started then Math.max(3, Math.min(10, Math.log(parseInt(d.started)))) + 2 else 6)
|
|
.attr("fill", line.lineColor)
|
|
.attr("stroke-width", 1)
|
|
.attr("class", "graph-point")
|
|
.attr("data-pointid", (d) -> "#{line.lineID}#{d.x}")
|
|
d3line = d3.svg.line()
|
|
.x((d) -> xRange(d.x))
|
|
.y((d) -> yRange(d.y))
|
|
.interpolate("linear")
|
|
svg.append("path")
|
|
.attr("d", d3line(line.points))
|
|
.attr("transform", "translate(" + (margin + yAxisWidth * graphLineCount) + "," + margin + ")")
|
|
.style("stroke-width", 1)
|
|
.style("stroke", line.lineColor)
|
|
.style("fill", "none")
|
|
currentLine++
|
|
|
|
getAnalytics: (startDay, endDay) =>
|
|
# Analytics APIs use 2 different day formats
|
|
if startDay?
|
|
startDayDashed = startDay
|
|
startDay = startDay.replace(/-/g, '')
|
|
else
|
|
startDay = utils.getUTCDay -14
|
|
startDayDashed = "#{startDay[0..3]}-#{startDay[4..5]}-#{startDay[6..7]}"
|
|
if endDay?
|
|
endDayDashed = endDay
|
|
endDay = endDay.replace(/-/g, '')
|
|
else
|
|
endDay = utils.getUTCDay -1
|
|
endDayDashed = "#{endDay[0..3]}-#{endDay[4..5]}-#{endDay[6..7]}"
|
|
|
|
# Initialize
|
|
@analytics =
|
|
startDay: startDayDashed
|
|
endDay: endDayDashed
|
|
commonProblems: {data: [], loading: true}
|
|
levelCompletions: {data: [], loading: true}
|
|
levelHelps: {data: [], loading: true}
|
|
levelPlaytimes: {data: [], loading: true}
|
|
recentSessions: {data: [], loading: true}
|
|
graphs: []
|
|
@render() # Hide old analytics data while we fetch new data
|
|
|
|
makeFinishDataFetch = (data) =>
|
|
return =>
|
|
return if @destroyed
|
|
@updateAnalyticsGraphData()
|
|
data.loading = false
|
|
@render()
|
|
@getCommonLevelProblems startDayDashed, endDayDashed, makeFinishDataFetch(@analytics.commonProblems)
|
|
@getLevelCompletions startDay, endDay, makeFinishDataFetch(@analytics.levelCompletions)
|
|
@getLevelHelps startDay, endDay, makeFinishDataFetch(@analytics.levelHelps)
|
|
@getLevelPlaytimes startDayDashed, endDayDashed, makeFinishDataFetch(@analytics.levelPlaytimes)
|
|
@getRecentSessions makeFinishDataFetch(@analytics.recentSessions)
|
|
|
|
getCommonLevelProblems: (startDay, endDay, doneCallback) ->
|
|
success = (data) =>
|
|
return doneCallback() if @destroyed
|
|
# console.log 'getCommonLevelProblems', data
|
|
@analytics.commonProblems.data = data
|
|
doneCallback()
|
|
request = @supermodel.addRequestResource 'common_problems', {
|
|
url: '/db/user.code.problem/-/common_problems'
|
|
data: {startDay: startDay, endDay: endDay, slug: @levelSlug}
|
|
method: 'POST'
|
|
success: success
|
|
}, 0
|
|
request.load()
|
|
|
|
getLevelCompletions: (startDay, endDay, doneCallback) ->
|
|
success = (data) =>
|
|
return doneCallback() if @destroyed
|
|
# console.log 'getLevelCompletions', data
|
|
data.sort (a, b) -> if a.created < b.created then -1 else 1
|
|
mapFn = (item) ->
|
|
item.rate = if item.started > 0 then item.finished / item.started * 100 else 0
|
|
item
|
|
@analytics.levelCompletions.data = _.map data, mapFn, @
|
|
doneCallback()
|
|
request = @supermodel.addRequestResource 'level_completions', {
|
|
url: '/db/analytics_perday/-/level_completions'
|
|
data: {startDay: startDay, endDay: endDay, slug: @levelSlug}
|
|
method: 'POST'
|
|
success: success
|
|
}, 0
|
|
request.load()
|
|
|
|
getLevelHelps: (startDay, endDay, doneCallback) ->
|
|
success = (data) =>
|
|
return doneCallback() if @destroyed
|
|
# console.log 'getLevelHelps', data
|
|
@analytics.levelHelps.data = data.sort (a, b) -> if a.day < b.day then -1 else 1
|
|
doneCallback()
|
|
request = @supermodel.addRequestResource 'level_helps', {
|
|
url: '/db/analytics_perday/-/level_helps'
|
|
data: {startDay: startDay, endDay: endDay, slugs: [@levelSlug]}
|
|
method: 'POST'
|
|
success: success
|
|
}, 0
|
|
request.load()
|
|
|
|
getLevelPlaytimes: (startDay, endDay, doneCallback) ->
|
|
success = (data) =>
|
|
return doneCallback() if @destroyed
|
|
# console.log 'getLevelPlaytimes', data
|
|
@analytics.levelPlaytimes.data = data.sort (a, b) -> if a.created < b.created then -1 else 1
|
|
doneCallback()
|
|
request = @supermodel.addRequestResource 'playtime_averages', {
|
|
url: '/db/level/-/playtime_averages'
|
|
data: {startDay: startDay, endDay: endDay, slugs: [@levelSlug]}
|
|
method: 'POST'
|
|
success: success
|
|
}, 0
|
|
request.load()
|
|
|
|
getRecentSessions: (doneCallback) ->
|
|
limit = 100
|
|
success = (data) =>
|
|
return doneCallback() if @destroyed
|
|
# console.log 'getRecentSessions', data
|
|
@analytics.recentSessions.data = data
|
|
doneCallback()
|
|
request = @supermodel.addRequestResource 'level_sessions_recent', {
|
|
url: "/db/level.session/-/recent"
|
|
data: {slug: @levelSlug, limit: limit}
|
|
method: 'POST'
|
|
success: success
|
|
}, 0
|
|
request.load()
|