mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-29 10:35:51 -05:00
019406a341
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.
466 lines
17 KiB
CoffeeScript
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()
|