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()