Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/org/maproulette/Config.scala
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ object Config {
val KEY_SCHEDULER_SNAPSHOT_CHALLENGES_INTERVAL =
s"$SUB_GROUP_SCHEDULER.challengesSnapshot.interval"
val KEY_SCHEDULER_SNAPSHOT_CHALLENGES_START = s"$SUB_GROUP_SCHEDULER.challengesSnapshot.startTime"
val KEY_SCHEDULER_TILE_REFRESH_INTERVAL = s"$SUB_GROUP_SCHEDULER.tileRefresh.interval"

val KEY_MAPROULETTE_FRONTEND = s"$GROUP_MAPROULETTE.frontend"
val SUB_GROUP_MAPILLARY = s"$GROUP_MAPROULETTE.mapillary"
Expand Down
99 changes: 99 additions & 0 deletions app/org/maproulette/framework/controller/TaskController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,105 @@ class TaskController @Inject() (
}
}

/**
* Gets task data using pre-computed tile aggregates for efficient map display at scale.
* Uses a tile pyramid system with pre-computed counts broken down by difficulty × global.
*
* Behavior by filter:
* - difficulty & global: Filtered from pre-computed tile data (fast)
* - location_id: Recursive tile drilling until within polygon or < 2000 tasks
* - keywords: Falls back to dynamic query (challenge-level filter, not pre-computed)
*
* All fetched data is re-clustered into ~80 clusters for display.
* When total tasks < 2000, returns individual task markers instead of clusters.
*
* @param z Zoom level (0-14 for pre-computed tiles)
* @param bounds Comma-separated bounding box: left,bottom,right,top
* @param global Whether to include global challenges
* @param location_id Optional Nominatim place_id for polygon filtering
* @param keywords Optional keywords filter (triggers fallback to dynamic query)
* @param difficulty Optional difficulty filter (1=Easy, 2=Normal, 3=Expert)
* @return TaskMarkerResponse with totalCount and either clusters or tasks (with overlaps)
*/
def getTaskTiles(
z: Int,
bounds: String,
global: Boolean,
location_id: Option[Long],
keywords: Option[String],
difficulty: Option[Int]
): Action[AnyContent] = Action.async { implicit request =>
this.sessionManager.userAwareRequest { implicit user =>
// Clamp zoom to valid range
val validZoom = math.max(0, math.min(20, z))

// Only allow valid difficulty values (1, 2, 3)
val validDifficulty = difficulty.filter(d => d >= 1 && d <= 3)

// Parse and validate bounding box
val boundingBox =
try {
bounds.split(",").map(_.trim.toDouble).toList match {
case List(left, bottom, right, top) =>
// Clamp coordinates to valid ranges
val clampedLeft = math.max(-180.0, math.min(180.0, left))
val clampedRight = math.max(-180.0, math.min(180.0, right))
val clampedBottom = math.max(-90.0, math.min(90.0, bottom))
val clampedTop = math.max(-90.0, math.min(90.0, top))
SearchLocation(clampedLeft, clampedBottom, clampedRight, clampedTop)
case _ =>
SearchLocation(-180.0, -90.0, 180.0, 90.0)
}
} catch {
case _: NumberFormatException =>
SearchLocation(-180.0, -90.0, 180.0, 90.0)
}

val response = this.serviceManager.tileAggregate.getTileData(
validZoom,
boundingBox,
validDifficulty,
global,
location_id,
keywords
)
Ok(Json.toJson(response))
}
}

/**
* Get task data for a specific tile (z/x/y).
* Used for zoom 14+ where frontend requests individual tiles for caching.
*
* @param z Zoom level
* @param x Tile X coordinate
* @param y Tile Y coordinate
* @param global Include global challenges
* @param difficulty Optional difficulty filter (1=Easy, 2=Normal, 3=Expert)
* @return TaskMarkerResponse with tasks for this tile
*/
def getTaskTile(
z: Int,
x: Int,
y: Int,
global: Boolean,
difficulty: Option[Int]
): Action[AnyContent] = Action.async { implicit request =>
this.sessionManager.userAwareRequest { implicit user =>
val validZoom = math.max(0, math.min(22, z))
val validDifficulty = difficulty.filter(d => d >= 1 && d <= 3)

val response = this.serviceManager.tileAggregate.getTileDataByCoords(
validZoom,
x,
y,
validDifficulty,
global
)
Ok(Json.toJson(response))
}
}

// for getting more detailed task marker data on individul makrers
// def getTaskMarkerData(id: Long): Action[AnyContent] = Action.async { implicit request =>
// this.sessionManager.userAwareRequest { implicit user =>
Expand Down
135 changes: 135 additions & 0 deletions app/org/maproulette/framework/model/TileAggregate.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md).
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
package org.maproulette.framework.model
import play.api.libs.json._

case class ClusterPoint(
lat: Double,
lng: Double,
count: Int
)

object ClusterPoint {
implicit val clusterPointFormat: Format[ClusterPoint] = Json.format[ClusterPoint]
}

case class FilterCounts(
d1_gf: Int = 0,
d1_gt: Int = 0,
d2_gf: Int = 0,
d2_gt: Int = 0,
d3_gf: Int = 0,
d3_gt: Int = 0,
d0_gf: Int = 0,
d0_gt: Int = 0
) {

/**
* Get count for specific difficulty and global filters
*
* @param difficulty Optional difficulty filter (1, 2, 3)
* @param global Whether to include global challenges (true = all, false = non-global only)
* @return Filtered count
*/
def getFilteredCount(difficulty: Option[Int], global: Boolean): Int = {
difficulty match {
case Some(1) => if (global) d1_gf + d1_gt else d1_gf
case Some(2) => if (global) d2_gf + d2_gt else d2_gf
case Some(3) => if (global) d3_gf + d3_gt else d3_gf
case Some(_) =>
// Unknown difficulty values use d0 (unset/other) bucket
if (global) d0_gf + d0_gt else d0_gf
case None =>
// No filter: sum all difficulty levels
if (global) d1_gf + d1_gt + d2_gf + d2_gt + d3_gf + d3_gt + d0_gf + d0_gt
else d1_gf + d2_gf + d3_gf + d0_gf
}
}

/**
* Get total count (all combinations)
*/
def total: Int = d1_gf + d1_gt + d2_gf + d2_gt + d3_gf + d3_gt + d0_gf + d0_gt
}

object FilterCounts {
implicit val filterCountsFormat: Format[FilterCounts] = Json.format[FilterCounts]

def fromJson(json: JsValue): FilterCounts = {
FilterCounts(
d1_gf = (json \ "d1_gf").asOpt[Int].getOrElse(0),
d1_gt = (json \ "d1_gt").asOpt[Int].getOrElse(0),
d2_gf = (json \ "d2_gf").asOpt[Int].getOrElse(0),
d2_gt = (json \ "d2_gt").asOpt[Int].getOrElse(0),
d3_gf = (json \ "d3_gf").asOpt[Int].getOrElse(0),
d3_gt = (json \ "d3_gt").asOpt[Int].getOrElse(0),
d0_gf = (json \ "d0_gf").asOpt[Int].getOrElse(0),
d0_gt = (json \ "d0_gt").asOpt[Int].getOrElse(0)
)
}
}

/**
* Represents a pre-computed task group at any zoom level (0-14).
*
* Zoom 0-13: One cluster per tile (group_type=2, no task_ids)
* - As zoom increases, tiles get smaller, clusters naturally split
* - Frontend displays these as cluster markers
*
* Zoom 14: One entry per overlap group (group_type=0 or 1, with task_ids)
* - Frontend handles clustering for zoom levels 14-22
* - Returns individual task markers and overlapping task markers
*
* @param id Database ID
* @param z Zoom level (0-14)
* @param x Tile X coordinate
* @param y Tile Y coordinate
* @param groupType 0=single task, 1=overlapping tasks, 2=cluster
* @param centroidLat Centroid latitude of the group
* @param centroidLng Centroid longitude of the group
* @param taskIds List of task IDs (empty for clusters at zoom 0-13)
* @param taskCount Number of tasks in this group
* @param countsByFilter Counts broken down by difficulty × global for filtering
*/
case class TileTaskGroup(
id: Long,
z: Int,
x: Int,
y: Int,
groupType: Int,
centroidLat: Double,
centroidLng: Double,
taskIds: List[Long],
taskCount: Int,
countsByFilter: FilterCounts
) {

/**
* Get the filtered count for this group based on difficulty and global filters
*/
def getFilteredCount(difficulty: Option[Int], global: Boolean): Int = {
countsByFilter.getFilteredCount(difficulty, global)
}

/**
* Check if this is a single task (zoom 14 only)
*/
def isSingle: Boolean = groupType == 0

/**
* Check if this is an overlapping group (zoom 14 only)
*/
def isOverlapping: Boolean = groupType == 1

/**
* Check if this is a cluster (zoom 0-13)
*/
def isCluster: Boolean = groupType == 2
}

object TileTaskGroup {
implicit val tileTaskGroupWrites: Writes[TileTaskGroup] = Json.writes[TileTaskGroup]
implicit val tileTaskGroupReads: Reads[TileTaskGroup] = Json.reads[TileTaskGroup]
}
Loading
Loading