diff --git a/app/controllers/AdminController.scala b/app/controllers/AdminController.scala index 0201bd5b67..442acb0e8a 100644 --- a/app/controllers/AdminController.scala +++ b/app/controllers/AdminController.scala @@ -13,7 +13,7 @@ import models.daos.slick.DBTableDefinitions.UserTable import models.label.LabelTable.LabelMetadata import models.label.{LabelPointTable, LabelTable} import models.mission.MissionTable -import models.region.RegionTable +import models.region.{RegionCompletionTable, RegionTable} import models.street.{StreetEdge, StreetEdgeTable} import models.user.User import org.geotools.geometry.jts.JTS @@ -21,6 +21,7 @@ import org.geotools.referencing.CRS import play.api.libs.json.{JsArray, JsObject, JsValue, Json} import play.extras.geojson + import scala.concurrent.Future /** @@ -102,29 +103,36 @@ class AdminController @Inject() (implicit val env: Environment[User, SessionAuth * @return */ def getNeighborhoodCompletionRate = UserAwareAction.async { implicit request => - if (isAdmin(request.identity)) { - - // http://docs.geotools.org/latest/tutorials/geometry/geometrycrs.html - val CRSEpsg4326 = CRS.decode("epsg:4326") - val CRSEpsg26918 = CRS.decode("epsg:26918") - val transform = CRS.findMathTransform(CRSEpsg4326, CRSEpsg26918) - + RegionCompletionTable.initializeRegionCompletionTable() + + val neighborhoods = RegionCompletionTable.selectAllNamedNeighborhoodCompletions + val completionRates: List[JsObject] = for (neighborhood <- neighborhoods) yield { + Json.obj("region_id" -> neighborhood.regionId, + "total_distance_m" -> neighborhood.totalDistance, + "completed_distance_m" -> neighborhood.auditedDistance, + "rate" -> (neighborhood.auditedDistance / neighborhood.totalDistance), + "name" -> neighborhood.name + ) + } - val neighborhoods = RegionTable.selectAllNamedNeighborhoods - val completionRates: List[JsObject] = for (neighborhood <- neighborhoods) yield { - val streets: List[StreetEdge] = StreetEdgeTable.selectStreetsByARegionId(neighborhood.regionId) - val auditedStreets: List[StreetEdge] = StreetEdgeTable.selectAuditedStreetsByARegionId(neighborhood.regionId) + Future.successful(Ok(JsArray(completionRates))) + } - val completedDistance = auditedStreets.map(s => JTS.transform(s.geom, transform).getLength).sum - val totalDistance = streets.map(s => JTS.transform(s.geom, transform).getLength).sum - Json.obj("region_id" -> neighborhood.regionId, - "total_distance_m" -> totalDistance, - "completed_distance_m" -> completedDistance, - "name" -> neighborhood.name + /** + * Returns DC coverage percentage by Date + * + * @return + */ + def getCompletionRateByDate = UserAwareAction.async { implicit request => + if (isAdmin(request.identity)) { + val streets: Seq[(String, Float)] = StreetEdgeTable.streetDistanceCompletionRateByDate(1) + val json = Json.arr(streets.map(x => { + Json.obj( + "date" -> x._1, "completion" -> x._2 ) - } + })) - Future.successful(Ok(JsArray(completionRates))) + Future.successful(Ok(json)) } else { Future.successful(Redirect("/")) } diff --git a/app/controllers/TaskController.scala b/app/controllers/TaskController.scala index 1a82cd029e..800442f431 100644 --- a/app/controllers/TaskController.scala +++ b/app/controllers/TaskController.scala @@ -107,11 +107,19 @@ class TaskController @Inject() (implicit val env: Environment[User, SessionAuthe def updateAuditTaskCompleteness(auditTaskId: Int, auditTask: TaskSubmission, incomplete: Option[IncompleteTaskSubmission]): Unit = { if (auditTask.completed.isDefined && auditTask.completed.get) { AuditTaskTable.updateCompleted(auditTaskId, completed=true) - StreetEdgeAssignmentCountTable.incrementCompletion(auditTask.streetEdgeId) + val updatedCount: Int = StreetEdgeAssignmentCountTable.incrementCompletion(auditTask.streetEdgeId) + // if this was the first completed audit of this street edge, increase total audited distance of that region. + if (updatedCount == 1) { + RegionCompletionTable.updateAuditedDistance(auditTask.streetEdgeId) + } } else if (incomplete.isDefined && incomplete.get.issueDescription == "GSVNotAvailable") { // If the user skipped with `GSVNotAvailable`, mark the task as completed and increment the task completion AuditTaskTable.updateCompleted(auditTaskId, completed=true) - StreetEdgeAssignmentCountTable.incrementCompletion(auditTask.streetEdgeId) // Increment task completion + val updatedCount: Int = StreetEdgeAssignmentCountTable.incrementCompletion(auditTask.streetEdgeId) // Increment task completion + // if this was the first completed audit of this street edge, increase total audited distance of that region. + if (updatedCount == 1) { + RegionCompletionTable.updateAuditedDistance(auditTask.streetEdgeId) + } } } diff --git a/app/models/audit/AuditTaskTable.scala b/app/models/audit/AuditTaskTable.scala index 45ca36b677..a1c5c4f961 100644 --- a/app/models/audit/AuditTaskTable.scala +++ b/app/models/audit/AuditTaskTable.scala @@ -109,10 +109,15 @@ object AuditTaskTable { auditTasks.list } + /** + * Returns a count of the number of audits performed on each day since the tool was launched (11/17/2015). + * + * @return + */ def auditCounts: List[AuditCountPerDay] = db.withSession { implicit session => val selectAuditCountQuery = Q.queryNA[(String, Int)]( """SELECT calendar_date::date, COUNT(audit_task_id) FROM (SELECT current_date - (n || ' day')::INTERVAL AS calendar_date - |FROM generate_series(0, 30) n) AS calendar + |FROM generate_series(0, current_date - '11/17/2015') n) AS calendar |LEFT JOIN sidewalk.audit_task |ON audit_task.task_start::date = calendar_date::date |GROUP BY calendar_date diff --git a/app/models/label/LabelTable.scala b/app/models/label/LabelTable.scala index 337e4f807f..efda8014fd 100644 --- a/app/models/label/LabelTable.scala +++ b/app/models/label/LabelTable.scala @@ -464,10 +464,15 @@ object LabelTable { // labelLocationList } + /** + * Returns a count of the number of labels placed on each day since the tool was launched (11/17/2015). + * + * @return + */ def selectLabelCountsPerDay: List[LabelCountPerDay] = db.withSession { implicit session => - val selectAuditCountQuery = Q.queryNA[(String, Int)]( + val selectLabelCountQuery = Q.queryNA[(String, Int)]( """SELECT calendar_date::date, COUNT(label_id) FROM (SELECT current_date - (n || ' day')::INTERVAL AS calendar_date - |FROM generate_series(0, 30) n) AS calendar + |FROM generate_series(0, current_date - '11/17/2015') n) AS calendar |LEFT JOIN sidewalk.audit_task |ON audit_task.task_start::date = calendar_date::date |LEFT JOIN sidewalk.label @@ -475,7 +480,7 @@ object LabelTable { |GROUP BY calendar_date |ORDER BY calendar_date""".stripMargin ) - selectAuditCountQuery.list.map(x => LabelCountPerDay.tupled(x)) + selectLabelCountQuery.list.map(x => LabelCountPerDay.tupled(x)) } } diff --git a/app/models/region/RegionCompletionTable.scala b/app/models/region/RegionCompletionTable.scala new file mode 100644 index 0000000000..cece008a67 --- /dev/null +++ b/app/models/region/RegionCompletionTable.scala @@ -0,0 +1,138 @@ +package models.region + +import java.util.UUID + +import com.vividsolutions.jts.geom.Polygon +import org.geotools.geometry.jts.JTS +import org.geotools.referencing.CRS + +import math._ +import models.street.{StreetEdgeAssignmentCountTable, StreetEdgeRegionTable, StreetEdgeTable, StreetEdge} +import models.user.UserCurrentRegionTable +import models.utils.MyPostgresDriver +import models.utils.MyPostgresDriver.simple._ +import play.api.Play.current + +import scala.slick.jdbc.{GetResult, StaticQuery => Q} +import scala.slick.lifted.ForeignKeyQuery + +case class RegionCompletion(regionId: Int, totalDistance: Double, auditedDistance: Double) +case class NamedRegionCompletion(regionId: Int, name: Option[String], totalDistance: Double, auditedDistance: Double) + +class RegionCompletionTable(tag: Tag) extends Table[RegionCompletion](tag, Some("sidewalk"), "region_completion") { + def regionId = column[Int]("region_id", O.PrimaryKey) + def totalDistance = column[Double]("total_distance") + def auditedDistance = column[Double]("audited_distance") + + def * = (regionId, totalDistance, auditedDistance) <> ((RegionCompletion.apply _).tupled, RegionCompletion.unapply) +} + +/** + * Data access object for the sidewalk_edge table + */ +object RegionCompletionTable { + import MyPostgresDriver.plainImplicits._ + + implicit val regionCompletionConverter = GetResult[RegionCompletion](r => { + RegionCompletion(r.nextInt, r.nextDouble, r.nextDouble) + }) + +// implicit val namedRegionConverter = GetResult[NamedRegion](r => { +// NamedRegion(r.nextInt, r.nextStringOption, r.nextGeometry[Polygon]) +// }) + + case class StreetCompletion(regionId: Int, regionName: String, streetEdgeId: Int, completionCount: Int, distance: Double) + implicit val streetCompletionConverter = GetResult[StreetCompletion](r => { + StreetCompletion(r.nextInt, r.nextString, r.nextInt, r.nextInt, r.nextDouble) + }) + + val db = play.api.db.slick.DB + val regionCompletions = TableQuery[RegionCompletionTable] + val regions = TableQuery[RegionTable] + val regionTypes = TableQuery[RegionTypeTable] + val regionProperties = TableQuery[RegionPropertyTable] + val streetEdges = TableQuery[StreetEdgeTable] + val streetEdgeAssignmentCounts = TableQuery[StreetEdgeAssignmentCountTable] + val streetEdgeRegion = TableQuery[StreetEdgeRegionTable] + val userCurrentRegions = TableQuery[UserCurrentRegionTable] + + val regionsWithoutDeleted = regions.filter(_.deleted === false) + val streetEdgesWithoutDeleted = streetEdges.filter(_.deleted === false) + val neighborhoods = regionsWithoutDeleted.filter(_.regionTypeId === 2) + val streetEdgeNeighborhood = for { (se, n) <- streetEdgeRegion.innerJoin(neighborhoods).on(_.regionId === _.regionId) } yield se + + + /** + * Returns a list of all neighborhoods with names + * @return + */ + def selectAllNamedNeighborhoodCompletions: List[NamedRegionCompletion] = db.withSession { implicit session => + val namedRegionCompletions = for { + (_neighborhoodCompletions, _regionProperties) <- regionCompletions.leftJoin(regionProperties).on(_.regionId === _.regionId) + if _regionProperties.key === "Neighborhood Name" + } yield (_neighborhoodCompletions.regionId, _regionProperties.value.?, _neighborhoodCompletions.totalDistance, _neighborhoodCompletions.auditedDistance) + + namedRegionCompletions.list.map(x => NamedRegionCompletion.tupled(x)) + } + /** + * + */ + + + /** + * Increments the `audited_distance` column of the corresponding region by the length of the specified street edge. + * Reference: http://slick.lightbend.com/doc/2.0.0/queries.html#updating + * + * @param streetEdgeId street edge id + * @return + */ + def updateAuditedDistance(streetEdgeId: Int) = db.withTransaction { implicit session => + val distToAdd: Float = streetEdgesWithoutDeleted.filter(_.streetEdgeId === streetEdgeId).groupBy(x => x).map(_._1.geom.transform(26918).length).list.head + val regionId: Int = streetEdgeNeighborhood.filter(_.streetEdgeId === streetEdgeId).groupBy(x => x).map(_._1.regionId).list.head + + val q = for { regionCompletion <- regionCompletions if regionCompletion.regionId === regionId } yield regionCompletion + + val updatedDist = q.firstOption match { + case Some(rC) => q.map(_.auditedDistance).update(rC.auditedDistance + distToAdd) + case None => -1 + } + updatedDist + } + + def initializeRegionCompletionTable() = db.withTransaction { implicit session => + + if (regionCompletions.length.run == 0) { + // http://docs.geotools.org/latest/tutorials/geometry/geometrycrs.html + val CRSEpsg4326 = CRS.decode("epsg:4326") + val CRSEpsg26918 = CRS.decode("epsg:26918") + val transform = CRS.findMathTransform(CRSEpsg4326, CRSEpsg26918) + + val neighborhoods = RegionTable.selectAllNamedNeighborhoods + for (neighborhood <- neighborhoods) yield { + val streets: List[StreetEdge] = StreetEdgeTable.selectStreetsByARegionId(neighborhood.regionId) + val auditedStreets: List[StreetEdge] = StreetEdgeTable.selectAuditedStreetsByARegionId(neighborhood.regionId) + + val auditedDistance = auditedStreets.map(s => JTS.transform(s.geom, transform).getLength).sum + val totalDistance = streets.map(s => JTS.transform(s.geom, transform).getLength).sum + + regionCompletions += RegionCompletion(neighborhood.regionId, totalDistance, auditedDistance) + } + } + } + +// +// /** +// * Update the `task_end` column of the specified audit task row +// * +// * @param auditTaskId +// * @param timestamp +// * @return +// */ +// def updateTaskEnd(auditTaskId: Int, timestamp: Timestamp) = db.withTransaction { implicit session => +// val q = for { task <- auditTasks if task.auditTaskId === auditTaskId } yield task.taskEnd +// q.update(Some(timestamp)) +// } + + + +} diff --git a/app/models/street/StreetEdgeAssignmentCountTable.scala b/app/models/street/StreetEdgeAssignmentCountTable.scala index 4706f83620..582e52cd44 100644 --- a/app/models/street/StreetEdgeAssignmentCountTable.scala +++ b/app/models/street/StreetEdgeAssignmentCountTable.scala @@ -65,7 +65,7 @@ object StreetEdgeAssignmentCountTable { def incrementCompletion(edgeId: Int): Int = db.withTransaction { implicit session => val q = for {counts <- streetEdgeAssignmentCounts if counts.streetEdgeId === edgeId} yield counts val count = q.firstOption match { - case Some(c) => q.map(_.completionCount).update(c.completionCount + 1) + case Some(c) => q.map(_.completionCount).update(c.completionCount + 1); c.completionCount + 1 // returns incremented completion count case None => 0 } count diff --git a/app/models/street/StreetEdgeTable.scala b/app/models/street/StreetEdgeTable.scala index 3bd0f0be05..6fd230d883 100644 --- a/app/models/street/StreetEdgeTable.scala +++ b/app/models/street/StreetEdgeTable.scala @@ -2,6 +2,8 @@ package models.street import java.sql.Timestamp import java.util.UUID +import java.util.Calendar +import java.text.SimpleDateFormat import com.vividsolutions.jts.geom.LineString import models.audit.AuditTaskTable @@ -101,6 +103,18 @@ object StreetEdgeTable { countAuditedStreets(auditCount).toFloat / allEdges.length } + /** + * Calculate the proportion of the total miles of DC that have been audited at least auditCount times. + * + * @param auditCount + * @return Float between 0 and 1 + */ + def streetDistanceCompletionRate(auditCount: Int): Float = db.withSession { implicit session => + val auditedDistance = auditedStreetDistance(auditCount) + val totalDistance = totalStreetDistance() + auditedDistance / totalDistance + } + /** * Get the total distance in miles * Reference: http://gis.stackexchange.com/questions/143436/how-do-i-calculate-st-length-in-miles @@ -130,6 +144,70 @@ object StreetEdgeTable { (distances.sum * 0.000621371).toFloat } + + /** + * Computes percentage of DC audited over time. + * + * author: Mikey Saugstad + * date: 06/16/2017 + * + * @param auditCount + * @return List[(String,Float)] representing dates and percentages + */ + def streetDistanceCompletionRateByDate(auditCount: Int): Seq[(String, Float)] = db.withSession { implicit session => + // join the street edges and audit tasks + // TODO figure out how to do this w/out doing the join twice + val edges = for { + (_streetEdges, _auditTasks) <- streetEdgesWithoutDeleted.innerJoin(completedAuditTasks).on(_.streetEdgeId === _.streetEdgeId) + } yield _streetEdges + val audits = for { + (_streetEdges, _auditTasks) <- streetEdgesWithoutDeleted.innerJoin(completedAuditTasks).on(_.streetEdgeId === _.streetEdgeId) + } yield _auditTasks + + // get distances of street edges associated with their edgeId + val edgeDists: Map[Int, Float] = edges.groupBy(x => x).map(g => (g._1.streetEdgeId, g._1.geom.transform(26918).length)).list.toMap + + // Filter out group of edges with the size less than the passed `auditCount`, picking 1 rep from each group + // TODO pick audit with earliest timestamp + val uniqueEdgeDists: List[(Option[Timestamp], Option[Float])] = (for ((eid, groupedAudits) <- audits.list.groupBy(_.streetEdgeId)) yield { + if (auditCount > 0 && groupedAudits.size >= auditCount) { + Some((groupedAudits.head.taskEnd, edgeDists.get(eid))) + } else { + None + } + }).toList.flatten + + // round the timestamps down to just the date (year-month-day) + val dateRoundedDists: List[(Calendar, Double)] = uniqueEdgeDists.map({ + pair => { + var c : Calendar = Calendar.getInstance() + c.setTimeInMillis(pair._1.get.getTime) + c.set(Calendar.HOUR_OF_DAY, 0) + c.set(Calendar.MINUTE, 0) + c.set(Calendar.SECOND, 0) + c.set(Calendar.MILLISECOND, 0) + (c, pair._2.get * 0.000621371) + }}) + + // sum the distances by date + val distsPerDay: List[(Calendar, Double)] = dateRoundedDists.groupBy(_._1).mapValues(_.map(_._2).sum).view.force.toList + + // sort the list by date + val sortedEdges: Seq[(Calendar, Double)] = + scala.util.Sorting.stableSort(distsPerDay, (e1: (Calendar,Double), e2: (Calendar, Double)) => e1._1.getTimeInMillis < e2._1.getTimeInMillis).toSeq + + // get the cumulative distance over time + val cumDistsPerDay: Seq[(Calendar, Double)] = sortedEdges.map({var dist = 0.0; pair => {dist += pair._2; (pair._1, dist)}}) + + // calculate the completion percentage for each day + val totalDist = totalStreetDistance() + val ratePerDay: Seq[(Calendar, Float)] = cumDistsPerDay.map(pair => (pair._1, (100.0 * pair._2 / totalDist).toFloat)) + + // format the calendar date in the correct format and return the (date,completionPercentage) pair + val format1 = new SimpleDateFormat("yyyy-MM-dd") + ratePerDay.map(pair => (format1.format(pair._1.getTime), pair._2)) + } + /** * Count the number of streets that have been audited at least a given number of times * diff --git a/app/views/admin/index.scala.html b/app/views/admin/index.scala.html index 8ce19f59e0..fcd1f46e73 100644 --- a/app/views/admin/index.scala.html +++ b/app/views/admin/index.scala.html @@ -132,25 +132,19 @@

Coverage

Coverage Total + Rate Audited Streets @StreetEdgeTable.countAuditedStreets() @StreetEdgeTable.countTotalStreets() + @("%.0f".format(StreetEdgeTable.auditCompletionRate(1) * 100))% Distance @("%.1f".format(StreetEdgeTable.auditedStreetDistance(1))) miles @("%.1f".format(StreetEdgeTable.totalStreetDistance())) miles - - - - Rate - - - @("%.0f".format(StreetEdgeTable.auditCompletionRate(1) * 100))% - - + @("%.0f".format(StreetEdgeTable.streetDistanceCompletionRate(1) * 100))% @@ -259,57 +253,94 @@

Comments

-
-

Activities

-
- - - - - - - - - - - - - -
Daily Audits
-
-
Daily Label Counts
-
-
+
+
+ +
-

Completed Missions

+ +

Coverage Percentage by Neighborhood

-
+
-

Onboarding Completion Time

+ + +

Neighborhood Coverage Percentages

- Bounce Rate: + Standard Deviation:

-
+
-
-
-

Neighborhood Statistics

+

DC Coverage Progress

-

Coverage Rate per Neighborhood (%)

-
-
+
-
-
+ +

Daily Audits

-

Coverage (m)

-
+

+ Standard Deviation:
+

+
+
+
+ +

Daily Label Counts

+
+

+ Standard Deviation:
+

+
+
+
+
+ +

Severity Ratings by Label Type

+
+
+
+

Onboarding Completion Time

+
+

+ Bounce Rate (Percent who did NOT finish tutorial):
+ Standard Deviation:
+ Note: Final column represents all completion times > 9 minutes. +

+
+
+
+
+
@@ -438,6 +469,13 @@

Anonymous Users

+ + + + + + + -} \ No newline at end of file +} diff --git a/app/views/developer.scala.html b/app/views/developer.scala.html index 24aca22d9b..93a8e8f39d 100644 --- a/app/views/developer.scala.html +++ b/app/views/developer.scala.html @@ -259,7 +259,7 @@

Warning

  1. APIs' design (e.g., URL, response formats) could change
  2. we are focusing on collecting data only from Washington, D.C. at the moment
  3. -
  4. since we have only covered @("%.0f".format(StreetEdgeTable.auditCompletionRate(1) * 100))% of the entire area of DC, +
  5. since we have only covered @("%.0f".format(StreetEdgeTable.streetDistanceCompletionRate(1) * 100))% of the entire area of DC, you would find neighborhoods where we don't have the accessibility data yet (note you can help us by contributing to data collection too ;)).
diff --git a/app/views/index.scala.html b/app/views/index.scala.html index cf24634211..022ce3f242 100644 --- a/app/views/index.scala.html +++ b/app/views/index.scala.html @@ -141,6 +141,47 @@
-->
+
+
+
Pick a Neighborhood
+
+
+
+ You are automatically assigned a neighborhood when you click 'Start Mapping', but you can also choose one by clicking on a region below! +
+
+
+
+
+ +
+
+
@@ -182,7 +223,7 @@
@@ -291,4 +332,17 @@ + + + + + + + + + } diff --git a/conf/application.conf b/conf/application.conf index 5b6cb161b0..b7c93f53eb 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -53,8 +53,15 @@ slick.default="models.*" # Evolutions # ~~~~~ # You can disable evolutions if needed -evolutionplugin=disabled -# applyEvolutions.default=true +evolutionplugin=enabled + +play.evolutions.autoApplyDown=true + +applyEvolutions.default=true + +# Transactional DDL - causes all statements to be executed in one transaction only +evolutions.autocommit=false + # https://www.playframework.com/documentation/2.3.x/HTTPServer trustxforwarded=true diff --git a/conf/evolutions/default/1.sql b/conf/evolutions/default/1.sql new file mode 100644 index 0000000000..1fc03a2b01 --- /dev/null +++ b/conf/evolutions/default/1.sql @@ -0,0 +1,13 @@ + +# --- !Ups +CREATE TABLE region_completion +( + region_id INTEGER NOT NULL, + total_distance REAL, + audited_distance REAL, + PRIMARY KEY (region_id) +); + +# --- !Downs + +DROP TABLE region_completion; diff --git a/conf/routes b/conf/routes index 7598144a7f..55584d2f1d 100644 --- a/conf/routes +++ b/conf/routes @@ -30,6 +30,7 @@ GET /admin/task/:taskId @controllers.AdminController.tas GET /adminapi/missionsCompletedByUsers @controllers.AdminController.getMissionsCompletedByUsers GET /adminapi/neighborhoodCompletionRate @controllers.AdminController.getNeighborhoodCompletionRate +GET /adminapi/completionRateByDate @controllers.AdminController.getCompletionRateByDate GET /adminapi/tasks/:username @controllers.AdminController.getSubmittedTasksWithLabels(username: String) GET /adminapi/interactions/:username @controllers.AdminController.getAuditTaskInteractionsOfAUser(username: String) GET /adminapi/onboardingInteractions @controllers.AdminController.getOnboardingTaskInteractions diff --git a/public/css/main.css b/public/css/main.css index 97a4800250..234f61af13 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -618,6 +618,12 @@ video#bgvid { min-height: 800px; } +#choropleth-container { + background-color: rgba(249, 241, 227, 1); + width: 100%; + min-height: 1100px; +} + #content{ position:relative; top:0px; diff --git a/public/javascripts/Admin/src/Admin.js b/public/javascripts/Admin/src/Admin.js index 00019c269c..f068a9a7c1 100644 --- a/public/javascripts/Admin/src/Admin.js +++ b/public/javascripts/Admin/src/Admin.js @@ -12,6 +12,8 @@ function Admin(_, $, c3, turf) { self.mapLoaded = false; self.graphsLoaded = false; + var neighborhoodPolygonLayer; + for (i = 0; i < 5; i++) { self.curbRampLayers[i] = []; self.missingCurbRampLayers[i] = []; @@ -32,312 +34,48 @@ function Admin(_, $, c3, turf) { L.mapbox.accessToken = 'pk.eyJ1Ijoia290YXJvaGFyYSIsImEiOiJDdmJnOW1FIn0.kJV65G6eNXs4ATjWCtkEmA'; - // Construct a bounding box for this map that the user cannot move out of + // Construct a bounding box for these maps that the user cannot move out of // https://www.mapbox.com/mapbox.js/example/v1.0.0/maxbounds/ - var southWest = L.latLng(38.761, -77.262), - northEast = L.latLng(39.060, -76.830), - bounds = L.latLngBounds(southWest, northEast), - - // var tileUrl = "https://a.tiles.mapbox.com/v4/kotarohara.mmoldjeh/page.html?access_token=pk.eyJ1Ijoia290YXJvaGFyYSIsImEiOiJDdmJnOW1FIn0.kJV65G6eNXs4ATjWCtkEmA#13/38.8998/-77.0638"; - tileUrl = "https:\/\/a.tiles.mapbox.com\/v4\/kotarohara.8e0c6890\/{z}\/{x}\/{y}.png?access_token=pk.eyJ1Ijoia290YXJvaGFyYSIsImEiOiJDdmJnOW1FIn0.kJV65G6eNXs4ATjWCtkEmA", - mapboxTiles = L.tileLayer(tileUrl, { - attribution: 'Terms & Feedback' - }), - map = L.mapbox.map('admin-map', "kotarohara.8e0c6890", { + var southWest = L.latLng(38.761, -77.262); + var northEast = L.latLng(39.060, -76.830); + var bounds = L.latLngBounds(southWest, northEast); + + // var tileUrl = "https://a.tiles.mapbox.com/v4/kotarohara.mmoldjeh/page.html?access_token=pk.eyJ1Ijoia290YXJvaGFyYSIsImEiOiJDdmJnOW1FIn0.kJV65G6eNXs4ATjWCtkEmA#13/38.8998/-77.0638"; + var tileUrl = "https:\/\/a.tiles.mapbox.com\/v4\/kotarohara.8e0c6890\/{z}\/{x}\/{y}.png?access_token=pk.eyJ1Ijoia290YXJvaGFyYSIsImEiOiJDdmJnOW1FIn0.kJV65G6eNXs4ATjWCtkEmA"; + var mapboxTiles = L.tileLayer(tileUrl, { + attribution: 'Terms & Feedback' + }); + var map = L.mapbox.map('admin-map', "kotarohara.8e0c6890", { + // set that bounding box as maxBounds to restrict moving the map + // see full maxBounds documentation: + // http://leafletjs.com/reference.html#map-maxbounds + maxBounds: bounds, + maxZoom: 19, + minZoom: 9 + }) + .fitBounds(bounds) + .setView([38.892, -77.038], 12); + + // a grayscale tileLayer for the choropleth + L.mapbox.accessToken = 'pk.eyJ1IjoibWlzYXVnc3RhZCIsImEiOiJjajN2dTV2Mm0wMDFsMndvMXJiZWcydDRvIn0.IXE8rQNF--HikYDjccA7Ug'; + var choropleth = L.mapbox.map('admin-choropleth', "kotarohara.8e0c6890", { // set that bounding box as maxBounds to restrict moving the map // see full maxBounds documentation: // http://leafletjs.com/reference.html#map-maxbounds maxBounds: bounds, maxZoom: 19, - minZoom: 9 + minZoom: 9, + legendControl: { + position: 'bottomleft' + } }) - // .addLayer(mapboxTiles) .fitBounds(bounds) - .setView([38.892, -77.038], 12), - popup = L.popup().setContent('

Hello world!
This is a nice popup.

'); - - // Draw an onboarding interaction chart - $.getJSON("/adminapi/onboardingInteractions", function (data) { - function cmp(a, b) { - return a.timestamp - b.timestamp; - } + .setView([38.892, -77.038], 12); + choropleth.scrollWheelZoom.disable(); - // Group the audit task interaction records by audit_task_id, then go through each group and compute - // the duration between the first time stamp and the last time stamp. - var grouped = _.groupBy(data, function (x) { - return x.audit_task_id; - }); - var completionDurationArray = []; - var record1; - var record2; - var duration; - for (var auditTaskId in grouped) { - grouped[auditTaskId].sort(cmp); - record1 = grouped[auditTaskId][0]; - record2 = grouped[auditTaskId][grouped[auditTaskId].length - 1]; - duration = (record2.timestamp - record1.timestamp) / 1000; // Duration in seconds - completionDurationArray.push(duration); - } - completionDurationArray.sort(function (a, b) { - return a - b; - }); + L.mapbox.styleLayer('mapbox://styles/mapbox/light-v9').addTo(choropleth); - // Bounce rate - var zeros = _.countBy(completionDurationArray, function (x) { - return x == 0; - }); - var bounceRate = zeros['true'] / (zeros['true'] + zeros['false']); - - // Histogram of duration - completionDurationArray = completionDurationArray.filter(function (x) { - return x != 0; - }); // Remove zeros - var numberOfBins = 10; - var histogram = makeAHistogramArray(completionDurationArray, numberOfBins); - // console.log(histogram); - var counts = histogram.histogram; - counts.unshift("Count"); - var bins = histogram.histogram.map(function (x, i) { - return (i * histogram.stepSize).toFixed(1) + " - " + ((i + 1) * histogram.stepSize).toFixed(1); - }); - - $("#onboarding-bounce-rate").html((bounceRate * 100).toFixed(1) + "%"); - - var chart = c3.generate({ - bindto: '#onboarding-completion-duration-histogram', - data: { - columns: [ - counts - ], - type: 'bar' - }, - axis: { - x: { - label: "Onboarding Completion Time (s)", - type: 'category', - categories: bins - }, - y: { - label: "Count", - min: 0, - padding: {top: 50, bottom: 10} - } - }, - legend: { - show: false - } - }); - }); - - $.getJSON('/adminapi/missionsCompletedByUsers', function (data) { - var i, - len = data.length; - - // Todo. This code double counts the missions completed for different region. So it should be fixed in the future. - var missions = {}; - var printedMissionName; - for (i = 0; i < len; i++) { - // Set the printed mission name - if (data[i].label == "initial-mission") { - printedMissionName = "Initial Mission (1000 ft)"; - } else if (data[i].label == "distance-mission") { - if (data[i].level <= 2) { - printedMissionName = "Distance Mission (" + data[i].distance_ft + " ft)"; - } else { - printedMissionName = "Distance Mission (" + data[i].distance_mi + " mi)"; - } - } else { - printedMissionName = "Onboarding"; - } - - // Create a counter for the printedMissionName if it does not exist yet. - if (!(printedMissionName in missions)) { - missions[printedMissionName] = { - label: data[i].label, - level: data[i].level, - printedMissionName: printedMissionName, - count: 0 - }; - } - missions[printedMissionName].count += 1; - } - var arrayOfMissions = Object.keys(missions).map(function (key) { - return missions[key]; - }); - arrayOfMissions.sort(function (a, b) { - if (a.count < b.count) { - return 1; - } - else if (a.count > b.count) { - return -1; - } - else { - return 0; - } - }); - - var missionCountArray = ["Mission Counts"]; - var missionNames = []; - for (i = 0; i < arrayOfMissions.length; i++) { - missionCountArray.push(arrayOfMissions[i].count); - missionNames.push(arrayOfMissions[i].printedMissionName); - } - var chart = c3.generate({ - bindto: '#completed-mission-histogram', - data: { - columns: [ - missionCountArray - ], - type: 'bar' - }, - axis: { - x: { - type: 'category', - categories: missionNames - }, - y: { - label: "# Users Completed the Mission", - min: 0, - padding: {top: 50, bottom: 10} - } - }, - legend: { - show: false - } - }); - }); - - $.getJSON('/adminapi/neighborhoodCompletionRate', function (data) { - var i, - len = data.length, - completionRate, - row, - rows = ""; - var coverageRateColumn = ["Neighborhood Coverage Rate (%)"]; - var coverageDistanceArray = ["Neighborhood Coverage (m)"]; - var neighborhoodNames = []; - for (i = 0; i < len; i++) { - completionRate = data[i].completed_distance_m / data[i].total_distance_m * 100; - coverageRateColumn.push(completionRate); - coverageDistanceArray.push(data[i].completed_distance_m); - - neighborhoodNames.push(data[i].name); - // row = "" + data[i].region_id + " " + data[i].name + "" + completionRate + "%" - // rows += row; - } - - var coverageChart = c3.generate({ - bindto: '#neighborhood-completion-rate', - data: { - columns: [ - coverageRateColumn - ], - type: 'bar' - }, - axis: { - x: { - type: 'category', - categories: neighborhoodNames - }, - y: { - label: "Neighborhood Coverage Rate (%)", - min: 0, - max: 100, - padding: {top: 50, bottom: 10} - } - }, - legend: { - show: false - } - }); - - var coverageDistanceChart = c3.generate({ - bindto: '#neighborhood-completed-distance', - data: { - columns: [ - coverageDistanceArray - ], - type: 'bar' - }, - axis: { - x: { - type: 'category', - categories: neighborhoodNames - }, - y: { - label: "Coverage Distance (m)", - min: 0, - padding: {top: 50, bottom: 10} - } - }, - legend: { - show: false - } - }); - - }); - - $.getJSON("/contribution/auditCounts/all", function (data) { - var dates = ['Date'].concat(data[0].map(function (x) { - return x.date; - })), - counts = ['Audit Count'].concat(data[0].map(function (x) { - return x.count; - })); - var chart = c3.generate({ - bindto: "#audit-count-chart", - data: { - x: 'Date', - columns: [dates, counts], - types: {'Audit Count': 'line'} - }, - axis: { - x: { - type: 'timeseries', - tick: {format: '%Y-%m-%d'} - }, - y: { - label: "Street Audit Count", - min: 0, - padding: {top: 50, bottom: 10} - } - }, - legend: { - show: false - } - }); - }); - - $.getJSON("/userapi/labelCounts/all", function (data) { - var dates = ['Date'].concat(data[0].map(function (x) { - return x.date; - })), - counts = ['Label Count'].concat(data[0].map(function (x) { - return x.count; - })); - var chart = c3.generate({ - bindto: "#label-count-chart", - data: { - x: 'Date', - columns: [dates, counts], - types: {'Audit Count': 'line'} - }, - axis: { - x: { - type: 'timeseries', - tick: {format: '%Y-%m-%d'} - }, - y: { - label: "Label Count", - min: 0, - padding: {top: 50, bottom: 10} - } - }, - legend: { - show: false - } - }); - }); + var popup = L.popup().setContent('

Hello world!
This is a nice popup.

'); // Initialize the map /** @@ -406,7 +144,7 @@ function Admin(_, $, c3, turf) { } $.getJSON("/neighborhoods", function (data) { - L.geoJson(data, { + neighborhoodPolygonLayer = L.geoJson(data, { style: function (feature) { return $.extend(true, {}, neighborhoodPolygonStyle); }, @@ -417,7 +155,166 @@ function Admin(_, $, c3, turf) { } /** - * This function queries the streets that the user audited and visualize them as segmetns on the map. + * Takes a completion percentage, bins it, and returns the appropriate color for a choropleth. + * + * @param p {float} represents a completion percentage, between 0 and 100 + * @returns {string} color in hex + */ + function getColor(p) { + return p > 80 ? '#08519c' : + p > 60 ? '#3182bd' : + p > 40 ? '#6baed6' : + p > 20 ? '#bdd7e7' : + '#eff3ff'; + } + function getColor2(p) { + return p > 90 ? '#08306b' : + p > 80 ? '#08519c' : + p > 70 ? '#08719c' : + p > 60 ? '#2171b5' : + p > 50 ? '#4292c6' : + p > 40 ? '#6baed6' : + p > 30 ? '#9ecae1' : + p > 20 ? '#c6dbef' : + p > 10 ? '#deebf7' : + '#f7fbff'; + } + function getColor3(p) { + return p > 90 ? '#023858' : + p > 80 ? '#045a8d' : + p > 70 ? '#0570b0' : + p > 60 ? '#3690c0' : + p > 50 ? '#74a9cf' : + p > 40 ? '#a6bddb' : + p > 30 ? '#d0d1e6' : + p > 20 ? '#ece7f2' : + p > 10 ? '#fff7fb' : + '#ffffff'; + } + function getColor4(p) { + return p > 80 ? '#045a8d' : + p > 60 ? '#2b8cbe' : + p > 40 ? '#74a9cf' : + p > 20 ? '#bdc9e1' : + '#f1eef6'; + } + function getOpacity(p) { + return p > 90 ? 1.0 : + p > 80 ? 0.9 : + p > 70 ? 0.8 : + p > 60 ? 0.7 : + p > 50 ? 0.6 : + p > 40 ? 0.5 : + p > 30 ? 0.4 : + p > 20 ? 0.3 : + p > 10 ? 0.2 : + 0.1; + } + + /** + * render the neighborhood polygons, colored by completion percentage + */ + function initializeChoroplethNeighborhoodPolygons(map, rates) { + var neighborhoodPolygonStyle = { // default bright red, used to check if any regions are missing data + color: '#888', + weight: 1, + opacity: 0.25, + fillColor: "#f00", + fillOpacity: 1.0 + }, + layers = [], + currentLayer; + + // finds the matching neighborhood's completion percentage, and uses it to determine the fill color + function style(feature) { + for (var i=0; i < rates.length; i++) { + if (rates[i].region_id === feature.properties.region_id) { + return { + color: '#888', + weight: 1, + opacity: 0.25, + fillColor: getColor2(rates[i].rate), + fillOpacity: 0.25 + (0.5 * rates[i].rate / 100.0) + } + } + } + return neighborhoodPolygonStyle; // default case (shouldn't happen, will be bright red) + } + + function onEachNeighborhoodFeature(feature, layer) { + + var regionId = feature.properties.region_id, + regionName = feature.properties.region_name, + compRate = -1.0, + milesLeft = -1.0, + url = "/audit/region/" + regionId, + popupContent = "???"; + for (var i=0; i < rates.length; i++) { + if (rates[i].region_id === feature.properties.region_id) { + compRate = Math.round(rates[i].rate); + milesLeft = Math.round(0.000621371 * (rates[i].total_distance_m - rates[i].completed_distance_m)); + if (compRate === 100) { + popupContent = "" + regionName + ": " + compRate + "\% Complete!"; + } + else if (milesLeft === 0) { + popupContent = "" + regionName + ": " + compRate + + "\% Complete
Less than a mile left!
" + + "Click here" + + " to help finish this neighborhood!"; + } + else if (milesLeft === 1) { + var popupContent = "" + regionName + ": " + compRate + "\% Complete
Only " + + milesLeft + " mile left!
" + + "Click here" + + " to help finish this neighborhood!"; + } + else { + var popupContent = "" + regionName + ": " + compRate + "\% Complete
Only " + + milesLeft + " miles left!
" + + "Click here" + + " to help finish this neighborhood!"; + } + break; + } + } + layer.bindPopup(popupContent); + layers.push(layer); + + layer.on('mouseover', function (e) { + this.setStyle({opacity: 1.0, weight: 3, color: "#000"}); + + }); + layer.on('mouseout', function (e) { + for (var i = layers.length - 1; i >= 0; i--) { + if (currentLayer !== layers[i]) + layers[i].setStyle({opacity: 0.25, weight: 1}); + } + //this.setStyle(neighborhoodPolygonStyle); + }); + layer.on('click', function (e) { + var center = turf.center(this.feature), + coordinates = center.geometry.coordinates, + latlng = L.latLng(coordinates[1], coordinates[0]), + zoom = map.getZoom(); + zoom = zoom > 14 ? zoom : 14; + + map.setView(latlng, zoom, {animate: true}); + currentLayer = this; + }); + } + + // adds the neighborhood polygons to the map + $.getJSON("/neighborhoods", function (data) { + neighborhoodPolygonLayer = L.geoJson(data, { + style: style, + onEachFeature: onEachNeighborhoodFeature + }) + .addTo(map); + }); + } + + /** + * This function queries the streets that the user audited and visualize them as segments on the map. */ function initializeAuditedStreets(map) { var distanceAudited = 0, // Distance audited in km @@ -618,27 +515,6 @@ function Admin(_, $, c3, turf) { } } - // A helper method to make an histogram of an array. - function makeAHistogramArray(arrayOfNumbers, numberOfBins) { - arrayOfNumbers.sort(function (a, b) { - return a - b; - }); - var stepSize = arrayOfNumbers[arrayOfNumbers.length - 1] / numberOfBins; - var dividedArray = arrayOfNumbers.map(function (x) { - return x / stepSize; - }); - var histogram = Array.apply(null, Array(numberOfBins)).map(Number.prototype.valueOf, 0); - for (var i = 0; i < dividedArray.length; i++) { - var binIndex = Math.floor(dividedArray[i] - 0.0000001); - histogram[binIndex] += 1; - } - return { - histogram: histogram, - stepSize: stepSize, - numberOfBins: numberOfBins - }; - } - function initializeAdminGSVLabelView() { self.adminGSVLabelView = AdminGSVLabel(); } @@ -663,6 +539,99 @@ function Admin(_, $, c3, turf) { self.mapLoaded = true; } else if (e.target.id == "analytics" && self.graphsLoaded == false) { + + + $.getJSON("/adminapi/completionRateByDate", function (data) { + var chart = { + // "height": 800, + "height": 300, + "width": 875, + "mark": "area", + "data": {"values": data[0], "format": {"type": "json"}}, + "encoding": { + "x": { + "field": "date", + "type": "temporal", + "axis": {"title": "Date", "labelAngle": 0} + }, + "y": { + "field": "completion", + "type": "quantitative", "scale": { + "domain": [0,100] + }, + "axis": { + "title": "DC Coverage (%)" + } + } + }, + // this is the slightly different code for the interactive version + // "vconcat": [ + // { + // "width": 800, + // "height": 150, + // "mark": "area", + // "selection": { + // "brush": { + // "type": "interval", "encodings": ["x"] + // } + // }, + // "encoding": { + // "x": { + // "field": "date", + // "type": "temporal", + // "axis": {"title": "Date", "labelAngle": 0} + // }, + // "y": { + // "field": "completion", + // "type": "quantitative", "scale": { + // "domain": [0,100] + // }, + // "axis": { + // "title": "DC Coverage (%)" + // } + // } + // } + // }, + // { + // "width": 800, + // "height": 400, + // "mark": "area", + // "encoding": { + // "x": { + // "field": "date", + // "type": "temporal", + // "scale": { + // "domain": { + // "selection": "brush", "encoding": "x" + // } + // }, + // "axis": { + // "title": "", "labelAngle": 0 + // } + // }, + // "y": { + // "field": "completion","type": "quantitative", "scale": { + // "domain": [0,100] + // }, + // "axis": { + // "title": "DC Coverage (%)" + // } + // } + // } + // } + // ], + "config": { + "axis": { + "titleFontSize": 16 + } + } + }; + var opt = { + "mode": "vega-lite", + "actions": false + }; + vega.embed("#completion-progress-chart", chart, opt, function(error, results) {}); + }); // Draw an onboarding interaction chart $.getJSON("/adminapi/onboardingInteractions", function (data) { function cmp(a, b) { @@ -674,273 +643,562 @@ function Admin(_, $, c3, turf) { var grouped = _.groupBy(data, function (x) { return x.audit_task_id; }); - var completionDurationArray = []; + var onboardingTimes = []; var record1; var record2; var duration; + var bounceCount = 0; + var sum = 0; for (var auditTaskId in grouped) { grouped[auditTaskId].sort(cmp); record1 = grouped[auditTaskId][0]; record2 = grouped[auditTaskId][grouped[auditTaskId].length - 1]; - duration = (record2.timestamp - record1.timestamp) / 1000; // Duration in seconds - completionDurationArray.push(duration); + if(record2.note === "from:outro" || record2.note === "onboardingTransition:outro"){ + duration = (record2.timestamp - record1.timestamp) / 60000; // Duration in minutes + onboardingTimes.push({duration: duration, binned: Math.min(10.0, duration)}); + sum += duration; + } + else bounceCount++; } - completionDurationArray.sort(function (a, b) { - return a - b; - }); - - // Bounce rate - var zeros = _.countBy(completionDurationArray, function (x) { - return x == 0; - }); - var bounceRate = zeros['true'] / (zeros['true'] + zeros['false']); - - // Histogram of duration - completionDurationArray = completionDurationArray.filter(function (x) { - return x != 0; - }); // Remove zeros - var numberOfBins = 10; - var histogram = makeAHistogramArray(completionDurationArray, numberOfBins); - // console.log(histogram); - var counts = histogram.histogram; - counts.unshift("Count"); - var bins = histogram.histogram.map(function (x, i) { - return (i * histogram.stepSize).toFixed(1) + " - " + ((i + 1) * histogram.stepSize).toFixed(1); - }); - + var bounceRate = bounceCount / (bounceCount + onboardingTimes.length); $("#onboarding-bounce-rate").html((bounceRate * 100).toFixed(1) + "%"); - var chart = c3.generate({ - bindto: '#onboarding-completion-duration-histogram', - data: { - columns: [ - counts - ], - type: 'bar' - }, - axis: { - x: { - label: "Onboarding Completion Time (s)", - type: 'category', - categories: bins + var mean = sum / onboardingTimes.length; + onboardingTimes.sort(function(a, b) {return (a.duration > b.duration) ? 1 : ((b.duration > a.duration) ? -1 : 0);} ); + var i = onboardingTimes.length / 2; + var median = i % 1 == 0 ? (onboardingTimes[i - 1].duration + onboardingTimes[i].duration) / 2 : onboardingTimes[Math.floor(i)].duration; + + var std = 0; + for(var j = 0; j < onboardingTimes.length; j++) { + std += Math.pow(onboardingTimes[j].duration - mean, 2); + } + std /= onboardingTimes.length; + std = Math.sqrt(std); + $("#onboarding-std").html((std).toFixed(1) + " minutes"); + + var chart = { + "width": 400, + "height": 250, + "layer": [ + { + "data": {"values": onboardingTimes}, + "mark": "bar", + "encoding": { + "x": { + "bin": {"maxbins": 10}, + "field": "binned", "type": "quantitative", + "axis": { + "title": "Onboarding Completion Time (minutes)", "labelAngle": 0, + "scale": {"domain": [0,10]} + } + }, + "y": { + "aggregate": "count", "field": "*", "type": "quantitative", + "axis": { + "title": "Counts" + } + } + } }, - y: { - label: "Count", - min: 0, - padding: {top: 50, bottom: 10} + { // creates lines marking summary statistics + "data": {"values": [ + {"stat": "mean", "value": mean}, {"stat": "median", "value": median}] + }, + "mark": "rule", + "encoding": { + "x": { + "field": "value", "type": "quantitative", + "axis": {"labels": false, "ticks": false, "title": ""}, + "scale": {"domain": [0,10]} + }, + "color": { + "field": "stat", "type": "nominal", "scale": {"range": ["pink", "orange"]}, + "legend": { + "title": "Summary Stats" + } + }, + "size": { + "value": 2 + } + } + } + ], + "resolve": {"x": {"scale": "independent"}}, + "config": { + "axis": { + "titleFontSize": 16 } - }, - legend: { - show: false } - }); + }; + var opt = { + "mode": "vega-lite", + "actions": false + }; + vega.embed("#onboarding-completion-duration-histogram", chart, opt, function(error, results) {}); }); - $.getJSON('/adminapi/missionsCompletedByUsers', function (data) { - var i, - len = data.length; - - // Todo. This code double counts the missions completed for different region. So it should be fixed in the future. - var missions = {}; - var printedMissionName; - for (i = 0; i < len; i++) { - // Set the printed mission name - if (data[i].label == "initial-mission") { - printedMissionName = "Initial Mission (1000 ft)"; - } else if (data[i].label == "distance-mission") { - if (data[i].level <= 2) { - printedMissionName = "Distance Mission (" + data[i].distance_ft + " ft)"; - } else { - printedMissionName = "Distance Mission (" + data[i].distance_mi + " mi)"; + $.getJSON('/adminapi/labels/all', function (data) { + for (var i = 0; i < data.features.length; i++) { + data.features[i].label_type = data.features[i].properties.label_type; + data.features[i].severity = data.features[i].properties.severity; + } + var curbRamps = data.features.filter(function(label) {return label.properties.label_type === "CurbRamp"}); + var noCurbRamps = data.features.filter(function(label) {return label.properties.label_type === "NoCurbRamp"}); + var surfaceProblems = data.features.filter(function(label) {return label.properties.label_type === "SurfaceProblem"}); + var obstacles = data.features.filter(function(label) {return label.properties.label_type === "Obstacle"}); + + var subPlotHeight = 200; + var subPlotWidth = 199; + var chart = { + "hconcat": [ + { + "height": subPlotHeight, + "width": subPlotWidth, + "data": {"values": curbRamps}, + "mark": "bar", + "encoding": { + "x": {"field": "severity", "type": "ordinal", "axis": {"title": "Curb Ramp Severity"}}, + "y": {"aggregate": "count", "type": "quantitative", "axis": {"title": "# of labels"}} + } + }, + { + "height": subPlotHeight, + "width": subPlotWidth, + "data": {"values": noCurbRamps}, + "mark": "bar", + "encoding": { + "x": {"field": "severity", "type": "ordinal", "axis": {"title": "Missing Curb Ramp Severity"}}, + "y": {"aggregate": "count", "type": "quantitative", "axis": {"title": ""}} + } + }, + { + "height": subPlotHeight, + "width": subPlotWidth, + "data": {"values": surfaceProblems}, + "mark": "bar", + "encoding": { + "x": {"field": "severity", "type": "ordinal", "axis": {"title": "Surface Problem Severity"}}, + "y": {"aggregate": "count", "type": "quantitative", "axis": {"title": ""}} + } + }, + { + "height": subPlotHeight, + "width": subPlotWidth, + "data": {"values": obstacles}, + "mark": "bar", + "encoding": { + "x": {"field": "severity", "type": "ordinal", "axis": {"title": "Obstacle Severity"}}, + "y": {"aggregate": "count", "type": "quantitative", "axis": {"title": ""}} + } } - } else { - printedMissionName = "Onboarding"; - } + ] + }; + var opt = { + "mode": "vega-lite", + "actions": false + }; + vega.embed("#severity-histograms", chart, opt, function(error, results) {}); + }); + $.getJSON('/adminapi/neighborhoodCompletionRate', function (data) { - // Create a counter for the printedMissionName if it does not exist yet. - if (!(printedMissionName in missions)) { - missions[printedMissionName] = { - label: data[i].label, - level: data[i].level, - printedMissionName: printedMissionName, - count: 0 - }; - } - missions[printedMissionName].count += 1; + // make a choropleth of neighborhood completion percentages + initializeChoroplethNeighborhoodPolygons(choropleth, data); + choropleth.legendControl.addLegend(document.getElementById('legend').innerHTML); + setTimeout(function () { + choropleth.invalidateSize(false); + }, 1); + + // make charts showing neighborhood completion rate + data.sort(function(a, b) {return (a.rate > b.rate) ? 1 : ((b.rate > a.rate) ? -1 : 0);} ); + var sum = 0; + for (var j = 0; j < data.length; j++) { + data[j].rate *= 100.0; + sum += data[j].rate; } - var arrayOfMissions = Object.keys(missions).map(function (key) { - return missions[key]; - }); - arrayOfMissions.sort(function (a, b) { - if (a.count < b.count) { - return 1; - } - else if (a.count > b.count) { - return -1; - } - else { - return 0; - } - }); + var mean = sum / data.length; + var i = data.length / 2; + var median = (data.length / 2) % 1 == 0 ? (data[i - 1].rate + data[i].rate) / 2 : data[Math.floor(i)].rate; - var missionCountArray = ["Mission Counts"]; - var missionNames = []; - for (i = 0; i < arrayOfMissions.length; i++) { - missionCountArray.push(arrayOfMissions[i].count); - missionNames.push(arrayOfMissions[i].printedMissionName); + var std = 0; + for(var k = 0; k < data.length; k++) { + std += Math.pow(data[k].rate - mean, 2); } - var chart = c3.generate({ - bindto: '#completed-mission-histogram', - data: { - columns: [ - missionCountArray - ], - type: 'bar' + std /= data.length; + std = Math.sqrt(std); + $("#neighborhood-std").html((std).toFixed(0) + "%"); + + var coverageRateChartSortedByCompletion = { + "width": 810, + "height": 800, + "data": { + "values": data, "format": { + "type": "json" + } }, - axis: { - x: { - type: 'category', - categories: missionNames + "mark": "bar", + "encoding": { + "x": { + "field": "rate", "type": "quantitative", + "axis": {"title": "Neighborhood Completion (%)"} }, - y: { - label: "# Users Completed the Mission", - min: 0, - padding: {top: 50, bottom: 10} + "y": { + "field": "name", "type": "nominal", + "axis": {"title": "Neighborhood"}, + "sort": {"field": "rate", "op": "max", "order": "ascending"} } }, - legend: { - show: false + "config": { + "axis": {"titleFontSize": 16, "labelFontSize": 8}, + "bar": {"binSpacing": 2} } - }); - }); - $.getJSON('/adminapi/neighborhoodCompletionRate', function (data) { - var i, - len = data.length, - completionRate, - row, - rows = ""; - var coverageRateColumn = ["Neighborhood Coverage Rate (%)"]; - var coverageDistanceArray = ["Neighborhood Coverage (m)"]; - var neighborhoodNames = []; - for (i = 0; i < len; i++) { - completionRate = data[i].completed_distance_m / data[i].total_distance_m * 100; - coverageRateColumn.push(completionRate); - coverageDistanceArray.push(data[i].completed_distance_m); - - neighborhoodNames.push(data[i].name); - // row = "" + data[i].region_id + " " + data[i].name + "" + completionRate + "%" - // rows += row; - } + }; - var coverageChart = c3.generate({ - bindto: '#neighborhood-completion-rate', - data: { - columns: [ - coverageRateColumn - ], - type: 'bar' + var coverageRateChartSortedAlphabetically = { + "width": 810, + "height": 800, + "data": { + "values": data, "format": { + "type": "json" + } }, - axis: { - x: { - type: 'category', - categories: neighborhoodNames + "mark": "bar", + "encoding": { + "x": { + "field": "rate", "type": "quantitative", + "axis": {"title": "Neighborhood Completion (%)"} }, - y: { - label: "Neighborhood Coverage Rate (%)", - min: 0, - max: 100, - padding: {top: 50, bottom: 10} + "y": { + "field": "name", "type": "nominal", + "axis": {"title": "Neighborhood"}, + "sort": {"field": "name", "op": "max", "order": "descending"} } }, - legend: { - show: false + "config": { + "axis": {"titleFontSize": 16, "labelFontSize": 8}, + "bar": {"binSpacing": 2} } + }; + var opt = { + "mode": "vega-lite", + "actions": false + }; + vega.embed("#neighborhood-completion-rate", coverageRateChartSortedByCompletion, opt, function(error, results) {}); + + document.getElementById("neighborhood-completion-sort-button").addEventListener("click", function() { + vega.embed("#neighborhood-completion-rate", coverageRateChartSortedByCompletion, opt, function(error, results) {}); + }); + document.getElementById("neighborhood-alphabetical-sort-button").addEventListener("click", function() { + vega.embed("#neighborhood-completion-rate", coverageRateChartSortedAlphabetically, opt, function(error, results) {}); }); - var coverageDistanceChart = c3.generate({ - bindto: '#neighborhood-completed-distance', - data: { - columns: [ - coverageDistanceArray - ], - type: 'bar' - }, - axis: { - x: { - type: 'category', - categories: neighborhoodNames + var coverageRateHist = { + "width": 400, + "height": 250, + "layer": [ + { + "data": {"values": data}, + "mark": "bar", + "encoding": { + "x": { + "bin": { + "maxbins": 10 + }, + "field": "rate", "type": "quantitative", + "axis": { + "title": "Neighborhood Completion (%)", "labelAngle": 0 + } + }, + "y": { + "aggregate": "count", "field": "*", "type": "quantitative", + "axis": { + "title": "Counts" + } + } + } }, - y: { - label: "Coverage Distance (m)", - min: 0, - padding: {top: 50, bottom: 10} + { // creates lines marking summary statistics + "data": {"values": [ + {"stat": "mean", "value": mean}, {"stat": "median", "value": median}] + }, + "mark": "rule", + "encoding": { + "x": { + "field": "value", "type": "quantitative", + "axis": {"labels": false, "ticks": false, "title": ""}, + "scale": {"domain": [0,100]} + }, + "color": { + "field": "stat", "type": "nominal", "scale": {"range": ["pink", "orange"]}, + "legend": { + "title": "Summary Stats" + } + }, + "size": { + "value": 2 + } + } + } + ], + "resolve": {"x": {"scale": "independent"}}, + "config": { + "axis": { + "titleFontSize": 16 } - }, - legend: { - show: false } - }); + }; + vega.embed("#neighborhood-completed-distance", coverageRateHist, opt, function(error, results) {}); }); $.getJSON("/contribution/auditCounts/all", function (data) { - var dates = ['Date'].concat(data[0].map(function (x) { - return x.date; - })), - counts = ['Audit Count'].concat(data[0].map(function (x) { - return x.count; - })); - var chart = c3.generate({ - bindto: "#audit-count-chart", - data: { - x: 'Date', - columns: [dates, counts], - types: {'Audit Count': 'line'} - }, - axis: { - x: { - type: 'timeseries', - tick: {format: '%Y-%m-%d'} + data[0].sort(function(a, b) {return (a.count > b.count) ? 1 : ((b.count > a.count) ? -1 : 0);} ); + var sum = 0; + for (var j = 0; j < data[0].length; j++) { + sum += data[0][j].count; + } + var mean = sum / data[0].length; + var i = data[0].length / 2; + var median = (data[0].length / 2) % 1 == 0 ? (data[0][i - 1].count + data[0][i].count) / 2 : data[0][Math.floor(i)].count; + + var std = 0; + for(var k = 0; k < data[0].length; k++) { + std += Math.pow(data[0][k].count - mean, 2); + } + std /= data[0].length; + std = Math.sqrt(std); + $("#audit-std").html((std).toFixed(1) + " Street Audits"); + + var chart = { + "data": {"values": data[0]}, + "hconcat": [ + { + "height": 300, + "width": 550, + "layer": [ + { + "mark": "area", + "encoding": { + "x": { + "field": "date", + "type": "temporal", + "axis": {"title": "Date", "labelAngle": 0} + }, + "y": { + "field": "count", + "type": "quantitative", + "axis": { + "title": "# Street Audits per Day" + } + } + } + }, + { // creates lines marking summary statistics + "data": {"values": [ + {"stat": "mean", "value": mean}, {"stat": "median", "value": median}] + }, + "mark": "rule", + "encoding": { + "y": { + "field": "value", "type": "quantitative", + "axis": {"labels": false, "ticks": false, "title": ""}, + "scale": {"domain": [0, data[0][data[0].length-1].count]} + }, + "color": { + "field": "stat", "type": "nominal", "scale": {"range": ["pink", "orange"]}, + "legend": false + }, + "size": { + "value": 1 + } + } + } + ], + "resolve": {"y": {"scale": "independent"}} }, - y: { - label: "Street Audit Count", - min: 0, - padding: {top: 50, bottom: 10} + { + "height": 300, + "width": 250, + "layer": [ + { + "mark": "bar", + "encoding": { + "x": { + "field": "count", + "type": "quantitative", + "axis": {"title": "# Street Audits per Day", "labelAngle": 0}, + "bin": {"maxbins": 20} + }, + "y": { + "aggregate": "count", + "field": "*", + "type": "quantitative", + "axis": { + "title": "Counts" + } + } + } + }, + { // creates lines marking summary statistics + "data": {"values": [ + {"stat": "mean", "value": mean}, {"stat": "median", "value": median}] + }, + "mark": "rule", + "encoding": { + "x": { + "field": "value", "type": "quantitative", + "axis": {"labels": false, "ticks": false, "title": ""}, + "scale": {"domain": [0, data[0][data[0].length-1].count]} + }, + "color": { + "field": "stat", "type": "nominal", "scale": {"range": ["pink", "orange"]}, + "legend": { + "title": "Summary Stats" + } + }, + "size": { + "value": 1 + } + } + } + ], + "resolve": {"x": {"scale": "independent"}} + } + ], + "config": { + "axis": { + "titleFontSize": 16 } - }, - legend: { - show: false } - }); + }; + var opt = { + "mode": "vega-lite", + "actions": false + }; + vega.embed("#audit-count-chart", chart, opt, function(error, results) {}); }); - $.getJSON("/userapi/labelCounts/all", function (data) { - var dates = ['Date'].concat(data[0].map(function (x) { - return x.date; - })), - counts = ['Label Count'].concat(data[0].map(function (x) { - return x.count; - })); - var chart = c3.generate({ - bindto: "#label-count-chart", - data: { - x: 'Date', - columns: [dates, counts], - types: {'Audit Count': 'line'} - }, - axis: { - x: { - type: 'timeseries', - tick: {format: '%Y-%m-%d'} + data[0].sort(function(a, b) {return (a.count > b.count) ? 1 : ((b.count > a.count) ? -1 : 0);} ); + var sum = 0; + for (var j = 0; j < data[0].length; j++) { + sum += data[0][j].count; + } + var mean = sum / data[0].length; + var i = data[0].length / 2; + var median = (data[0].length / 2) % 1 == 0 ? (data[0][i - 1].count + data[0][i].count) / 2 : data[0][Math.floor(i)].count; + + var std = 0; + for(var k = 0; k < data[0].length; k++) { + std += Math.pow(data[0][k].count - mean, 2); + } + std /= data[0].length; + std = Math.sqrt(std); + $("#label-std").html((std).toFixed(0) + " Labels"); + + var chart = { + "data": {"values": data[0]}, + "hconcat": [ + { + "height": 300, + "width": 550, + "layer": [ + { + "mark": "area", + "encoding": { + "x": { + "field": "date", + "type": "temporal", + "axis": {"title": "Date", "labelAngle": 0} + }, + "y": { + "field": "count", + "type": "quantitative", + "axis": { + "title": "# Labels per Day" + } + } + } + }, + { // creates lines marking summary statistics + "data": {"values": [ + {"stat": "mean", "value": mean}, {"stat": "median", "value": median}] + }, + "mark": "rule", + "encoding": { + "y": { + "field": "value", "type": "quantitative", + "axis": {"labels": false, "ticks": false, "title": ""}, + "scale": {"domain": [0, data[0][data[0].length-1].count]} + }, + "color": { + "field": "stat", "type": "nominal", "scale": {"range": ["pink", "orange"]}, + "legend": false + }, + "size": { + "value": 2 + } + } + } + ], + "resolve": {"y": {"scale": "independent"}} }, - y: { - label: "Label Count", - min: 0, - padding: {top: 50, bottom: 10} + { + "height": 300, + "width": 250, + "layer": [ + { + "mark": "bar", + "encoding": { + "x": { + "field": "count", + "type": "quantitative", + "axis": {"title": "# Labels per Day", "labelAngle": 0}, + "bin": {"maxbins": 20} + }, + "y": { + "aggregate": "count", + "field": "*", + "type": "quantitative", + "axis": { + "title": "Counts" + } + } + } + }, + { // creates lines marking summary statistics + "data": {"values": [ + {"stat": "mean", "value": mean}, {"stat": "median", "value": median}] + }, + "mark": "rule", + "encoding": { + "x": { + "field": "value", "type": "quantitative", + "axis": {"labels": false, "ticks": false, "title": ""}, + "scale": {"domain": [0, data[0][data[0].length-1].count]} + }, + "color": { + "field": "stat", "type": "nominal", "scale": {"range": ["pink", "orange"]}, + "legend": { + "title": "Summary Stats" + } + }, + "size": { + "value": 2 + } + } + } + ], + "resolve": {"x": {"scale": "independent"}} + } + ], + "config": { + "axis": { + "titleFontSize": 16 } - }, - legend: { - show: false } - }); + }; + var opt = { + "mode": "vega-lite", + "actions": false + }; + vega.embed("#label-count-chart", chart, opt, function(error, results) {}); }); self.graphsLoaded = true; } diff --git a/public/javascripts/Choropleth.js b/public/javascripts/Choropleth.js new file mode 100644 index 0000000000..c6698bee4c --- /dev/null +++ b/public/javascripts/Choropleth.js @@ -0,0 +1,167 @@ +function Choropleth(_, $, turf) { + var neighborhoodPolygonLayer; + +// Construct a bounding box for these maps that the user cannot move out of +// https://www.mapbox.com/mapbox.js/example/v1.0.0/maxbounds/ + var southWest = L.latLng(38.761, -77.262); + var northEast = L.latLng(39.060, -76.830); + var bounds = L.latLngBounds(southWest, northEast); + +// var tileUrl = "https://a.tiles.mapbox.com/v4/kotarohara.mmoldjeh/page.html?access_token=pk.eyJ1Ijoia290YXJvaGFyYSIsImEiOiJDdmJnOW1FIn0.kJV65G6eNXs4ATjWCtkEmA#13/38.8998/-77.0638"; + var tileUrl = "https:\/\/a.tiles.mapbox.com\/v4\/kotarohara.8e0c6890\/{z}\/{x}\/{y}.png?access_token=pk.eyJ1Ijoia290YXJvaGFyYSIsImEiOiJDdmJnOW1FIn0.kJV65G6eNXs4ATjWCtkEmA"; + var mapboxTiles = L.tileLayer(tileUrl, { + attribution: 'Terms & Feedback' + }); + +// a grayscale tileLayer for the choropleth + L.mapbox.accessToken = 'pk.eyJ1IjoibWlzYXVnc3RhZCIsImEiOiJjajN2dTV2Mm0wMDFsMndvMXJiZWcydDRvIn0.IXE8rQNF--HikYDjccA7Ug'; + var choropleth = L.mapbox.map('choropleth', "kotarohara.8e0c6890", { + // set that bounding box as maxBounds to restrict moving the map + // see full maxBounds documentation: + // http://leafletjs.com/reference.html#map-maxbounds + maxBounds: bounds, + maxZoom: 19, + minZoom: 9, + legendControl: { + position: 'bottomleft' + } + }) + .fitBounds(bounds) + .setView([38.892, -77.038], 12); + choropleth.scrollWheelZoom.disable(); + + L.mapbox.styleLayer('mapbox://styles/mapbox/light-v9').addTo(choropleth); + + + /** + * Takes a completion percentage, bins it, and returns the appropriate color for a choropleth. + * + * @param p {float} represents a completion percentage, between 0 and 100 + * @returns {string} color in hex + */ + + function getColor(p) { + return p > 90 ? '#08306b' : + p > 80 ? '#08519c' : + p > 70 ? '#08719c' : + p > 60 ? '#2171b5' : + p > 50 ? '#4292c6' : + p > 40 ? '#6baed6' : + p > 30 ? '#9ecae1' : + p > 20 ? '#c6dbef' : + p > 10 ? '#deebf7' : + '#f7fbff'; + } + + /** + * render the neighborhood polygons, colored by completion percentage + */ + function initializeChoroplethNeighborhoodPolygons(map, rates) { + var neighborhoodPolygonStyle = { // default bright red, used to check if any regions are missing data + color: '#888', + weight: 1, + opacity: 0.25, + fillColor: "#f00", + fillOpacity: 1.0 + }, + layers = [], + currentLayer; + + // finds the matching neighborhood's completion percentage, and uses it to determine the fill color + function style(feature) { + for (var i = 0; i < rates.length; i++) { + if (rates[i].region_id === feature.properties.region_id) { + + return { + color: '#888', + weight: 1, + opacity: 0.25, + fillColor: getColor(100.0 * rates[i].rate), + fillOpacity: 0.25 + (0.5 * rates[i].rate) + } + } + } + return neighborhoodPolygonStyle; // default case (shouldn't happen, will be bright red) + } + + function onEachNeighborhoodFeature(feature, layer) { + + var regionId = feature.properties.region_id, + regionName = feature.properties.region_name, + compRate = -1.0, + milesLeft = -1.0, + url = "/audit/region/" + regionId, + popupContent = "???"; + for (var i = 0; i < rates.length; i++) { + if (rates[i].region_id === feature.properties.region_id) { + compRate = Math.round(100.0 * rates[i].rate); + milesLeft = Math.round(0.000621371 * (rates[i].total_distance_m - rates[i].completed_distance_m)); + if (compRate === 100) { + popupContent = "" + regionName + ": " + compRate + "\% Complete!"; + } + else if (milesLeft === 0) { + popupContent = "" + regionName + ": " + compRate + + "\% Complete
Less than a mile left!
" + + "Click here" + + " to help finish this neighborhood!"; + } + else if (milesLeft === 1) { + var popupContent = "" + regionName + ": " + compRate + "\% Complete
Only " + + milesLeft + " mile left!
" + + "Click here" + + " to help finish this neighborhood!"; + } + else { + var popupContent = "" + regionName + ": " + compRate + "\% Complete
Only " + + milesLeft + " miles left!
" + + "Click here" + + " to help finish this neighborhood!"; + } + break; + } + } + layer.bindPopup(popupContent); + layers.push(layer); + + layer.on('mouseover', function (e) { + this.setStyle({opacity: 1.0, weight: 3, color: "#000"}); + + }); + layer.on('mouseout', function (e) { + for (var i = layers.length - 1; i >= 0; i--) { + if (currentLayer !== layers[i]) + layers[i].setStyle({opacity: 0.25, weight: 1}); + } + }); + layer.on('click', function (e) { + var center = turf.center(this.feature), + coordinates = center.geometry.coordinates, + latlng = L.latLng(coordinates[1], coordinates[0]), + zoom = map.getZoom(); + zoom = zoom > 14 ? zoom : 14; + + map.setView(latlng, zoom, {animate: true}); + currentLayer = this; + }); + } + + // adds the neighborhood polygons to the map + $.getJSON("/neighborhoods", function (data) { + neighborhoodPolygonLayer = L.geoJson(data, { + style: style, + onEachFeature: onEachNeighborhoodFeature + }) + .addTo(map); + }); + } + + + $.getJSON('/adminapi/neighborhoodCompletionRate', function (data) { + // make a choropleth of neighborhood completion percentages + initializeChoroplethNeighborhoodPolygons(choropleth, data); + choropleth.legendControl.addLegend(document.getElementById('legend').innerHTML); + setTimeout(function () { + choropleth.invalidateSize(false); + }, 1); + }); +} \ No newline at end of file diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 799876b31f..030337626a 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -19,6 +19,23 @@ height: 800px; } +#admin-choropleth { + width: 100%; + height: 800px; +} + +.legend label, +.legend span { + display:block; + float:right; + height:15px; + width:10%; + text-align:left; + font-size:8px; + color:#808080; +} + + #map-label-legend { position: absolute; background: rgba(255, 255, 255, 0.75); diff --git a/public/stylesheets/choropleth.css b/public/stylesheets/choropleth.css new file mode 100644 index 0000000000..15184acb97 --- /dev/null +++ b/public/stylesheets/choropleth.css @@ -0,0 +1,15 @@ +#choropleth { + width: 100%; + height: 800px; +} + +.legend label, +.legend span { + display:block; + float:right; + height:15px; + width:10%; + text-align:left; + font-size:8px; + color:#808080; +} \ No newline at end of file