codecombat/app/views/editor/campaign/CampaignLevelView.coffee
Matt Lott 019406a341 Update campaign analytics
Increase line graph dots for larger hover targets.
Update missing day data handling to fill in graph points for any
missing day, not just most recent end days.
Fix level completion div0 bug.
2015-01-26 14:58:38 -08:00

466 lines
17 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'
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()
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()