Add line charts to admin analytics dashboard

https://app.asana.com/0/54276215890539/64369256136957
This commit is contained in:
Matt Lott 2015-11-10 11:20:35 -08:00
parent 5e4fccf775
commit e33323e7eb
4 changed files with 461 additions and 78 deletions

127
app/core/d3_utils.coffee Normal file
View file

@ -0,0 +1,127 @@
# Caller needs require 'vendor/d3'
module.exports.createContiguousDays = (timeframeDays) ->
# Return list of last 'timeframeDays' contiguous days in yyyy-mm-dd format
days = []
currentDate = new Date()
currentDate.setUTCDate(currentDate.getUTCDate() - timeframeDays)
for i in [0..timeframeDays]
currentDay = currentDate.toISOString().substr(0, 10)
days.push(currentDay)
currentDate.setUTCDate(currentDate.getUTCDate() + 1)
days
module.exports.createLineChart = (containerSelector, chartLines) ->
# Creates a line chart within 'containerSelector' based on chartLines
return unless chartLines?.length > 0 and containerSelector
margin = 20
keyHeight = 20
xAxisHeight = 20
yAxisWidth = 40
containerWidth = $(containerSelector).width()
containerHeight = $(containerSelector).height()
yScaleCount = 0
yScaleCount++ for line in chartLines when line.showYScale
svg = d3.select(containerSelector).append("svg")
.attr("width", containerWidth)
.attr("height", containerHeight)
width = containerWidth - margin * 2 - yAxisWidth * yScaleCount
height = containerHeight - margin * 2 - xAxisHeight - keyHeight * chartLines.length
currentLine = 0
currentYScale = 0
# Horizontal guidelines
marks = (Math.round(i * height / 5) for i in [1..5])
yRange = d3.scale.linear().range([height, 0]).domain([0, height])
svg.selectAll(".line")
.data(marks)
.enter()
.append("line")
.attr("x1", margin + yAxisWidth * yScaleCount)
.attr("y1", (d) -> margin + yRange(d))
.attr("x2", margin + yAxisWidth * yScaleCount + width)
.attr("y2", (d) -> margin + yRange(d))
.attr("stroke", 'gray')
.style("opacity", "0.3")
for line in chartLines
# 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
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) + "," + (height + margin) + ")")
.style("text-anchor", "start")
if line.showYScale
# 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 * currentYScale) + "," + margin + ")")
.style("color", line.lineColor)
.call(yAxis)
.selectAll("text")
.attr("y", 0)
.attr("x", 0)
.attr("fill", line.lineColor)
.style("text-anchor", "start")
currentYScale++
# 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 * yScaleCount) + "," + margin + ")")
.attr("cx", (d) -> xRange(d.x))
.attr("cy", (d) -> yRange(d.y))
.attr("r", 2)
.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 * yScaleCount) + "," + margin + ")")
.style("stroke-width", line.strokeWidth)
.style("stroke", line.lineColor)
.style("fill", "none")
currentLine++

View file

@ -14,3 +14,15 @@
font-size: 70pt
.description
font-size: 8pt
.line-chart-container
height: 500px
width: 100%
.x.axis
font-size: 9pt
path
display: none
.y.axis
font-size: 9pt
path
display: none

View file

@ -1,7 +1,9 @@
extends /templates/base
block content
//- NOTE: do not localize / i18n
if me.isAdmin()
.container-fluid
.row
@ -18,6 +20,21 @@ block content
div.description 30-day Active Users
div.count= activeUsers[0].monthlyCount
h3 KPI 60 days
.kpi-recent-chart.line-chart-container
h3 KPI 300 days
.kpi-chart.line-chart-container
h3 Active Classes 90 days
.active-classes-chart.line-chart-container
h3 Recurring Revenue 90 days
.recurring-revenue-chart.line-chart-container
h3 Active Users 90 days
.active-users-chart.line-chart-container
h1 Active Classes
table.table.table-striped.table-condensed
tr

View file

@ -1,3 +1,5 @@
require 'vendor/d3'
d3Utils = require 'core/d3_utils'
RootView = require 'views/core/RootView'
template = require 'templates/admin/analytics'
utils = require 'core/utils'
@ -5,86 +7,11 @@ utils = require 'core/utils'
module.exports = class AnalyticsView extends RootView
id: 'admin-analytics-view'
template: template
lineColors: ['red', 'blue', 'green', 'purple', 'goldenrod', 'brown', 'darkcyan']
constructor: (options) ->
super options
@supermodel.addRequestResource('active_classes', {
url: '/db/analytics_perday/-/active_classes'
method: 'POST'
success: (data) =>
@activeClassGroups = {}
dayEventsMap = {}
for activeClass in data
dayEventsMap[activeClass.day] ?= {}
dayEventsMap[activeClass.day]['Total'] = 0
for event, val of activeClass.classes
@activeClassGroups[event] = true
dayEventsMap[activeClass.day][event] = val
dayEventsMap[activeClass.day]['Total'] += val
@activeClassGroups = Object.keys(@activeClassGroups)
@activeClassGroups.push 'Total'
for day of dayEventsMap
for event in @activeClassGroups
dayEventsMap[day][event] ?= 0
@activeClasses = []
for day of dayEventsMap
data = day: day, groups: []
for group in @activeClassGroups
data.groups.push(dayEventsMap[day][group] ? 0)
@activeClasses.push data
@activeClasses.sort (a, b) -> b.day.localeCompare(a.day)
@render?()
}, 0).load()
@supermodel.addRequestResource('active_users', {
url: '/db/analytics_perday/-/active_users'
method: 'POST'
success: (data) =>
@activeUsers = data
@activeUsers.sort (a, b) -> b.day.localeCompare(a.day)
@render?()
}, 0).load()
@supermodel.addRequestResource('recurring_revenue', {
url: '/db/analytics_perday/-/recurring_revenue'
method: 'POST'
success: (data) =>
@revenueGroups = {}
dayGroupCountMap = {}
for dailyRevenue in data
dayGroupCountMap[dailyRevenue.day] ?= {}
dayGroupCountMap[dailyRevenue.day]['Daily'] = 0
for group, val of dailyRevenue.groups
@revenueGroups[group] = true
dayGroupCountMap[dailyRevenue.day][group] = val
dayGroupCountMap[dailyRevenue.day]['Daily'] += val
@revenueGroups = Object.keys(@revenueGroups)
@revenueGroups.push 'Daily'
@revenueGroups.push 'Monthly'
for day of dayGroupCountMap
for group in @revenueGroups
dayGroupCountMap[day][group] ?= 0
@revenue = []
for day of dayGroupCountMap
data = day: day, groups: []
for group in @revenueGroups
data.groups.push(dayGroupCountMap[day][group] ? 0)
@revenue.push data
@revenue.sort (a, b) -> b.day.localeCompare(a.day)
monthlyValues = []
return unless @revenue.length > 0
for i in [@revenue.length-1..0]
dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 2]
monthlyValues.push(dailyTotal)
monthlyValues.shift() if monthlyValues.length > 30
if monthlyValues.length is 30
monthlyIndex = @revenue[i].groups.length - 1
@revenue[i].groups[monthlyIndex] = _.reduce(monthlyValues, (s, num) -> s + num)
@render?()
}, 0).load()
@loadData()
getRenderData: ->
context = super()
@ -94,3 +21,303 @@ module.exports = class AnalyticsView extends RootView
context.revenue = @revenue ? []
context.revenueGroups = @revenueGroups ? {}
context
afterRender: ->
super()
@createLineCharts()
loadData: ->
@supermodel.addRequestResource('active_classes', {
url: '/db/analytics_perday/-/active_classes'
method: 'POST'
success: (data) =>
# Organize data by day, then group
groupMap = {}
dayGroupMap = {}
for activeClass in data
dayGroupMap[activeClass.day] ?= {}
dayGroupMap[activeClass.day]['Total'] = 0
for group, val of activeClass.classes
groupMap[group] = true
dayGroupMap[activeClass.day][group] = val
dayGroupMap[activeClass.day]['Total'] += val
@activeClassGroups = Object.keys(groupMap)
@activeClassGroups.push 'Total'
# Build list of active classes, where each entry is a day of individual group values
@activeClasses = []
for day of dayGroupMap
dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}"
data = day: dashedDay, groups: []
for group in @activeClassGroups
data.groups.push(dayGroupMap[day][group] ? 0)
@activeClasses.push data
@activeClasses.sort (a, b) -> b.day.localeCompare(a.day)
@updateAllKPIChartData()
@updateActiveClassesChartData()
@render?()
}, 0).load()
@supermodel.addRequestResource('active_users', {
url: '/db/analytics_perday/-/active_users'
method: 'POST'
success: (data) =>
@activeUsers = data.map (a) ->
a.day = "#{a.day.substring(0, 4)}-#{a.day.substring(4, 6)}-#{a.day.substring(6, 8)}"
a
@activeUsers.sort (a, b) -> b.day.localeCompare(a.day)
@updateAllKPIChartData()
@updateActiveUsersChartData()
@render?()
}, 0).load()
@supermodel.addRequestResource('recurring_revenue', {
url: '/db/analytics_perday/-/recurring_revenue'
method: 'POST'
success: (data) =>
# Organize data by day, then group
groupMap = {}
dayGroupCountMap = {}
for dailyRevenue in data
dayGroupCountMap[dailyRevenue.day] ?= {}
dayGroupCountMap[dailyRevenue.day]['Daily'] = 0
for group, val of dailyRevenue.groups
groupMap[group] = true
dayGroupCountMap[dailyRevenue.day][group] = val
dayGroupCountMap[dailyRevenue.day]['Daily'] += val
@revenueGroups = Object.keys(groupMap)
@revenueGroups.push 'Daily'
# Build list of recurring revenue entries, where each entry is a day of individual group values
@revenue = []
for day of dayGroupCountMap
dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}"
data = day: dashedDay, groups: []
for group in @revenueGroups
data.groups.push(dayGroupCountMap[day][group] ? 0)
@revenue.push data
@revenue.sort (a, b) -> b.day.localeCompare(a.day)
return unless @revenue.length > 0
# Add monthly recurring revenue values
@revenueGroups.push 'Monthly'
monthlyValues = []
for i in [@revenue.length-1..0]
dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 1]
monthlyValues.push(dailyTotal)
monthlyValues.shift() while monthlyValues.length > 30
if monthlyValues.length is 30
@revenue[i].groups.push(_.reduce(monthlyValues, (s, num) -> s + num))
@updateAllKPIChartData()
@updateRevenueChartData()
@render?()
}, 0).load()
createLineChartPoints: (days, data) ->
points = []
for entry, i in data
points.push
x: i
y: entry.value
day: entry.day
# Ensure points for each day
for day, i in days
if points.length <= i or points[i].day isnt day
prevY = if i > 0 then points[i - 1].y else 0.0
points.splice i, 0,
y: prevY
day: day
points[i].x = i
points.splice(0, points.length - days.length) if points.length > days.length
points
createLineCharts: ->
d3Utils.createLineChart('.kpi-recent-chart', @kpiRecentChartLines)
d3Utils.createLineChart('.kpi-chart', @kpiChartLines)
d3Utils.createLineChart('.active-classes-chart', @activeClassesChartLines)
d3Utils.createLineChart('.active-users-chart', @activeUsersChartLines)
d3Utils.createLineChart('.recurring-revenue-chart', @revenueChartLines)
updateAllKPIChartData: ->
@kpiRecentChartLines = []
@kpiChartLines = []
@updateKPIChartData(60, @kpiRecentChartLines)
@updateKPIChartData(300, @kpiChartLines)
updateKPIChartData: (timeframeDays, chartLines) ->
days = d3Utils.createContiguousDays(timeframeDays)
if @activeClasses?.length > 0
data = []
for entry in @activeClasses
data.push
day: entry.day
value: entry.groups[entry.groups.length - 1]
data.reverse()
points = @createLineChartPoints(days, data)
chartLines.push
points: points
description: '30-day Active Classes'
lineColor: 'blue'
strokeWidth: 1
min: 0
max: _.max(points, 'y').y
showYScale: true
if @revenue?.length > 0
data = []
for entry in @revenue
data.push
day: entry.day
value: entry.groups[entry.groups.length - 1] / 100000
data.reverse()
points = @createLineChartPoints(days, data)
chartLines.push
points: points
description: '30-day Recurring Revenue (in thousands)'
lineColor: 'green'
strokeWidth: 1
min: 0
max: _.max(points, 'y').y
showYScale: true
if @activeUsers?.length > 0
data = []
for entry in @activeUsers
break unless entry.monthlyCount
data.push
day: entry.day
value: entry.monthlyCount / 1000
data.reverse()
points = @createLineChartPoints(days, data)
chartLines.push
points: points
description: '30-day Active Users (in thousands)'
lineColor: 'red'
strokeWidth: 1
min: 0
max: _.max(points, 'y').y
showYScale: true
updateActiveClassesChartData: ->
@activeClassesChartLines = []
return unless @activeClasses?.length
days = d3Utils.createContiguousDays(90)
groupDayMap = {}
for entry in @activeClasses
for count, i in entry.groups
groupDayMap[@activeClassGroups[i]] ?= {}
groupDayMap[@activeClassGroups[i]][entry.day] ?= 0
groupDayMap[@activeClassGroups[i]][entry.day] += count
lines = []
colorIndex = 0
totalMax = 0
for group, entries of groupDayMap
data = []
for day, count of entries
data.push
day: day
value: count
data.reverse()
points = @createLineChartPoints(days, data)
@activeClassesChartLines.push
points: points
description: group.replace('Active classes ', '')
lineColor: @lineColors[colorIndex++ % @lineColors.length]
strokeWidth: 1
min: 0
showYScale: group is 'Total'
totalMax = _.max(points, 'y').y if group is 'Total'
line.max = totalMax for line in @activeClassesChartLines
updateActiveUsersChartData: ->
@activeUsersChartLines = []
return unless @activeUsers?.length
days = d3Utils.createContiguousDays(90)
dailyData = []
monthlyData = []
dausmausData = []
colorIndex = 0
for entry in @activeUsers
dailyData.push
day: entry.day
value: entry.dailyCount / 1000
if entry.monthlyCount
monthlyData.push
day: entry.day
value: entry.monthlyCount / 1000
dausmausData.push
day: entry.day
value: Math.round(entry.dailyCount / entry.monthlyCount * 100)
dailyData.reverse()
monthlyData.reverse()
dausmausData.reverse()
dailyPoints = @createLineChartPoints(days, dailyData)
monthlyPoints = @createLineChartPoints(days, monthlyData)
dausmausPoints = @createLineChartPoints(days, dausmausData)
@activeUsersChartLines.push
points: dailyPoints
description: 'Daily active users (in thousands)'
lineColor: @lineColors[colorIndex++ % @lineColors.length]
strokeWidth: 1
min: 0
max: _.max(dailyPoints, 'y').y
showYScale: true
@activeUsersChartLines.push
points: monthlyPoints
description: 'Monthly active users (in thousands)'
lineColor: @lineColors[colorIndex++ % @lineColors.length]
strokeWidth: 1
min: 0
max: _.max(monthlyPoints, 'y').y
showYScale: true
@activeUsersChartLines.push
points: dausmausPoints
description: 'DAUs/MAUs %'
lineColor: @lineColors[colorIndex++ % @lineColors.length]
strokeWidth: 1
min: 0
max: _.max(dausmausPoints, 'y').y
showYScale: true
updateRevenueChartData: ->
@revenueChartLines = []
return unless @revenue?.length
days = d3Utils.createContiguousDays(90)
groupDayMap = {}
for entry in @revenue
for count, i in entry.groups
groupDayMap[@revenueGroups[i]] ?= {}
groupDayMap[@revenueGroups[i]][entry.day] ?= 0
groupDayMap[@revenueGroups[i]][entry.day] += count
lines = []
colorIndex = 0
dailyMax = 0
for group, entries of groupDayMap
data = []
for day, count of entries
data.push
day: day
value: count / 100
data.reverse()
points = @createLineChartPoints(days, data)
@revenueChartLines.push
points: points
description: group.replace('DRR ', '')
lineColor: @lineColors[colorIndex++ % @lineColors.length]
strokeWidth: 1
min: 0
max: _.max(points, 'y').y
showYScale: group in ['Daily', 'Monthly']
dailyMax = _.max(points, 'y').y if group is 'Daily'
for line in @revenueChartLines when line.description isnt 'Monthly'
line.max = dailyMax