Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c3051c2
when querying tags, sort by label tag frequency
bella-buchanan Feb 18, 2025
5ba972f
Revert "when querying tags, sort by label tag frequency"
bella-buchanan Feb 18, 2025
8177ecc
when querying tags, sort by label tag frequency - without the mess
bella-buchanan Feb 18, 2025
c4f9ca0
ADD: group mutually exclusive tags together
bella-buchanan Feb 27, 2025
ea74def
Merge branch 'develop' into 3578-order-tags-by-frequency-of-use
bella-buchanan Apr 8, 2025
8cb20e8
use popularity to sort tags
bella-buchanan Apr 8, 2025
689f274
sort by new field
bella-buchanan Apr 8, 2025
5dab3a1
backend needs to give popularity based on mutually exclusive so they …
bella-buchanan Apr 8, 2025
60a31ee
sort tags by popularity on FE for explore page
bella-buchanan Apr 8, 2025
1008d2b
Merge branch 'develop' into 3578-order-tags-by-frequency-of-use
bella-buchanan Apr 8, 2025
24f771f
Merge branch 'develop' into 3578-order-tags-by-frequency-of-use
bella-buchanan Apr 12, 2025
85be3be
actually do the sorting on the FE
bella-buchanan Apr 14, 2025
9d1623c
change name to count and fix sorting
bella-buchanan Apr 14, 2025
aa9bc4b
add count to tag object
bella-buchanan Apr 15, 2025
65783a6
revert context menu
bella-buchanan Apr 15, 2025
9a3711d
Revert "revert context menu"
bella-buchanan Apr 15, 2025
25844a2
include count in tag writes
bella-buchanan Apr 15, 2025
83f9ba8
sort tags on FE in newValidateBeta
bella-buchanan Apr 15, 2025
dcd4401
common utility method to sort tags
bella-buchanan Apr 15, 2025
ca2c6e7
fix formatting dif
bella-buchanan Apr 15, 2025
0662c75
type annotation
bella-buchanan Apr 15, 2025
9fa6d7e
Merge branch 'develop' into 3578-order-tags-by-frequency-of-use
bella-buchanan Apr 29, 2025
7a22219
Merge branch 'develop' into 3578-order-tags-by-frequency-of-use
bella-buchanan May 20, 2025
6bfc854
detailed tag case class
bella-buchanan May 20, 2025
30fb15a
fix validate tag display
bella-buchanan May 20, 2025
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
26 changes: 20 additions & 6 deletions app/controllers/LabelController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import com.mohiva.play.silhouette.api.{Environment, Silhouette}
import com.mohiva.play.silhouette.impl.authenticators.SessionAuthenticator
import controllers.headers.ProvidesHeader
import models.label._
import models.label.{LabelTable, LabelTypeTable, LabelValidationTable, Tag, TagCount, TagTable, DetailedTag}
import models.user.User
import play.api.libs.json._
import play.api.mvc.Action
import scala.concurrent.Future
import models.gsv.GSVDataTable
import play.api.Logger

/**
* Holds the HTTP requests associated with getting label data.
*
Expand Down Expand Up @@ -75,12 +77,24 @@ class LabelController @Inject() (implicit val env: Environment[User, SessionAuth
*/
def getLabelTags() = Action.async { implicit request =>
val tags: List[Tag] = TagTable.getTagsForCurrentCity
Future.successful(Ok(JsArray(tags.map { tag => Json.obj(
"tag_id" -> tag.tagId,
"label_type" -> LabelTypeTable.labelTypeIdToLabelType(tag.labelTypeId).get,
"tag" -> tag.tag,
"mutually_exclusive_with" -> tag.mutuallyExclusiveWith
)})))
val tagCounts: List[TagCount] = LabelTable.getTagCounts()

val tagCountMap: Map[(String, String), Int] = tagCounts.map(tc => (tc.labelType, tc.tag) -> tc.count).toMap

val tagsWithCount: Seq[JsObject] = tags.map { tag =>
val labelType = LabelTypeTable.labelTypeIdToLabelType(tag.labelTypeId).getOrElse("")
val count = tagCountMap.getOrElse((labelType, tag.tag), 0)
val detailedTag = DetailedTag(tag.tagId, tag.labelTypeId, tag.tag, tag.mutuallyExclusiveWith, count)

Json.obj(
"tag_id" -> detailedTag.tagId,
"label_type" -> labelType,
"tag" -> detailedTag.tag,
"mutually_exclusive_with" -> detailedTag.mutuallyExclusiveWith,
"count" -> detailedTag.count
)
}
Future.successful(Ok(JsArray(tagsWithCount)))
}
}

Expand Down
18 changes: 16 additions & 2 deletions app/controllers/ValidationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import formats.json.CommentSubmissionFormats._
import formats.json.LabelFormat
import models.amt.AMTAssignmentTable
import models.daos.slick.DBTableDefinitions.{DBUser, UserTable}
import models.label.{LabelTable, LabelTypeTable, LabelValidationTable, Tag, TagTable}
import models.label.{LabelTable, LabelTypeTable, LabelValidationTable, Tag, TagCount, TagTable, DetailedTag}
import models.label.LabelTable.{AdminValidationData, LabelValidationMetadata}
import models.mission.{Mission, MissionSetProgress, MissionTable}
import models.region.{Region, RegionTable}
Expand All @@ -24,6 +24,7 @@ import play.api.libs.json._
import play.api.Logger
import play.api.i18n.Lang
import play.api.mvc._

import javax.naming.AuthenticationException
import scala.concurrent.Future
import scala.util.Try
Expand Down Expand Up @@ -114,8 +115,21 @@ class ValidationController @Inject() (implicit val env: Environment[User, Sessio
// If all went well, load the data for Admin NewValidateBeta with the specified filters.
val adminParams: AdminValidateParams = AdminValidateParams(adminVersion=true, parsedLabelTypeId.flatten, userIdsList.map(_.flatten), neighborhoodIdList.map(_.flatten))
val validationData = getDataForValidationPages(request, labelCount=10, "Visit_NewValidateBeta", adminParams)

// Add counts to corresponding tags
val tags: List[Tag] = TagTable.getTagsForCurrentCity
Future.successful(Ok(views.html.newValidateBeta("Sidewalk - NewValidateBeta", request.identity, adminParams, validationData._1, validationData._2, validationData._3, validationData._4.numComplete, validationData._5, validationData._6, tags)))
val tagCounts: List[TagCount] = LabelTable.getTagCounts()

val tagCountMap: Map[(String, String), Int] = tagCounts.map(tc => (tc.labelType, tc.tag) -> tc.count).toMap

val tagsWithCounts: List[DetailedTag] = tags.map { tag =>
val labelType = LabelTypeTable.labelTypeIdToLabelType(tag.labelTypeId).getOrElse("")
val count = tagCountMap.getOrElse((labelType, tag.tag), 0)
DetailedTag(tag.tagId, tag.labelTypeId, tag.tag, tag.mutuallyExclusiveWith, count)
}


Future.successful(Ok(views.html.newValidateBeta("Sidewalk - NewValidateBeta", request.identity, adminParams, validationData._1, validationData._2, validationData._3, validationData._4.numComplete, validationData._5, validationData._6, tagsWithCounts)))
}
} else {
Future.failed(new AuthenticationException("This is a beta currently only open to Admins."))
Expand Down
12 changes: 11 additions & 1 deletion app/formats/json/LabelFormat.scala
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,17 @@ object LabelFormat {
implicit val tagWrites: Writes[Tag] = (
(__ \ "tag_id").write[Int] and
(__ \ "label_type_id").write[Int] and
(__ \ "tag_name").write[String] and
(__ \ "tag").write[String] and
(__ \ "mutually_exclusive_with").writeNullable[String]
)(unlift(Tag.unapply))

implicit val detailedTagWrites: Writes[DetailedTag] = new Writes[DetailedTag] {
def writes(tag: DetailedTag) = Json.obj(
"tag_id" -> tag.tagId,
"label_type_id" -> tag.labelTypeId,
"tag" -> tag.tag,
"mutually_exclusive_with" -> tag.mutuallyExclusiveWith,
"count" -> tag.count
)
}
}
6 changes: 5 additions & 1 deletion app/models/label/TagTable.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import scala.slick.lifted.{ForeignKeyQuery, Index}

case class Tag(tagId: Int, labelTypeId: Int, tag: String, mutuallyExclusiveWith: Option[String])

case class DetailedTag(tagId: Int, labelTypeId: Int, tag: String, mutuallyExclusiveWith: Option[String], count: Int)

class TagTable(tagParam: slick.lifted.Tag) extends Table[Tag](tagParam, "tag") {
def tagId: Column[Int] = column[Int]("tag_id", O.PrimaryKey, O.AutoInc)
def labelTypeId: Column[Int] = column[Int]("label_type_id")
def tag: Column[String] = column[String]("tag")
def mutuallyExclusiveWith: Column[Option[String]] = column[Option[String]]("mutually_exclusive_with")

def * = (tagId, labelTypeId, tag, mutuallyExclusiveWith) <> ((Tag.apply _).tupled, Tag.unapply)
def * = (tagId, labelTypeId, tag, mutuallyExclusiveWith) <> ((Tag.apply(_: Int, _: Int, _: String, _: Option[String])).tupled,
{ t: Tag => Some((t.tagId, t.labelTypeId, t.tag, t.mutuallyExclusiveWith)) })

def labelType: ForeignKeyQuery[LabelTypeTable, LabelType] =
foreignKey("tag_label_type_id_fkey", labelTypeId, TableQuery[LabelTypeTable])(_.labelTypeId)
Expand Down Expand Up @@ -50,6 +53,7 @@ object TagTable {
}
}


def selectTagsByLabelType(labelType: String): List[Tag] = db.withSession { implicit session =>
Cache.getOrElse(s"selectTagsByLabelType($labelType)") {
tagTable
Expand Down
6 changes: 3 additions & 3 deletions app/views/newValidateBeta.scala.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
@import models.amt.AMTAssignmentTable
@import play.api.libs.json.JsValue
@import controllers.helper.ValidateHelper.AdminValidateParams
@import models.label.Tag
@import models.label.{Tag, DetailedTag}
@import play.api.libs.json.Json
@import play.api.libs.json.JsArray
@import formats.json.LabelFormat.tagWrites
@(title: String, user: Option[User] = None, adminParams: AdminValidateParams, mission: Option[JsValue], labelList: Option[JsValue], progress: Option[JsValue], missionSetProgress: Int, hasNextMission: Boolean, completedValidations: Int, tagList: List[Tag])(implicit lang: Lang)
@import formats.json.LabelFormat.{tagWrites, detailedTagWrites}
@(title: String, user: Option[User] = None, adminParams: AdminValidateParams, mission: Option[JsValue], labelList: Option[JsValue], progress: Option[JsValue], missionSetProgress: Int, hasNextMission: Boolean, completedValidations: Int, tagList: List[DetailedTag])(implicit lang: Lang)

@main(title, Some("/newValidateBeta")) {
@navbar(user, Some("/newValidateBeta"))
Expand Down
Empty file added docker-compose-override.yml
Empty file.
5 changes: 3 additions & 2 deletions public/javascripts/SVLabel/src/SVLabel/canvas/ContextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ function ContextMenu (uiContextMenu) {
url: "/label/tags",
type: 'get',
success: function(json) {
self.labelTags = json;
// Sort tags by popularity and group mutually exclusive tags using the common utility function
self.labelTags = util.sortTagsByPopularityAndGroupMutuallyExclusive(json, 'count', 'tag', 'mutually_exclusive_with');
},
error: function(result) {
throw result;
Expand Down Expand Up @@ -629,4 +630,4 @@ function ContextMenu (uiContextMenu) {
self.enableTagging = enableTagging;
self.isTaggingDisabled = isTaggingDisabled;
return self;
}
}
34 changes: 20 additions & 14 deletions public/javascripts/SVValidate/src/menu/RightMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,17 @@ function RightMenu(menuUI) {
$tagSelect = $('#select-tag').selectize({
maxItems: 1,
placeholder: 'Add more tags here',
labelField: 'tag_name',
valueField: 'tag_name',
searchField: 'tag_name',
sortField: 'popularity', // TODO include data abt frequency of use on this server.
labelField: 'tag',
valueField: 'tag',
searchField: 'tag',
onFocus: function() { svv.tracker.push('Click=TagSearch'); },
onItemAdd: function (value, $item) {
let currLabel = svv.panorama.getCurrentLabel();

// If the tag is mutually exclusive with another tag that's been added, remove the other tag.
const allTags = svv.tagsByLabelType[currLabel.getAuditProperty('labelType')];
const mutuallyExclusiveWith = allTags.find(t => t.tag_name === value).mutually_exclusive_with;
const currTags = currLabel.getProperty('newTags');
const mutuallyExclusiveWith = allTags.find(t => t.tag === value).mutually_exclusive_with;
const currTags = currLabel.getProperty('newTags') || [];
if (currTags.some(t => t === mutuallyExclusiveWith)) {
svv.tracker.push(`TagAutoRemove_Tag="${mutuallyExclusiveWith}"`);
currLabel.setProperty('newTags', currTags.filter(t => t !== mutuallyExclusiveWith));
Expand All @@ -72,7 +71,7 @@ function RightMenu(menuUI) {
render: {
option: function(item, escape) {
// Add an example image tooltip to the tag.
const translatedTagName = i18next.t('common:tag.' + item.tag_name.replace(/:/g, '-'));
const translatedTagName = i18next.t('common:tag.' + item.tag.replace(/:/g, '-'));
let $tagDiv = $(`<div class="option">${escape(translatedTagName)}</div>`);
const tooltipText = `"${translatedTagName}" example`
_addTooltip($tagDiv, tooltipText, `/assets/images/examples/tags/${item.tag_id}.png`);
Expand Down Expand Up @@ -291,26 +290,28 @@ function RightMenu(menuUI) {
function _removeTag(e, label) {
let allTagOptions = structuredClone(svv.tagsByLabelType[label.getAuditProperty('labelType')]);
let tagIdToRemove = $(e.target).parents('.current-tag').data('tag-id');
let tagToRemove = allTagOptions.find(t => t.tag_id === tagIdToRemove).tag_name;
let tagToRemove = allTagOptions.find(t => t.tag_id === tagIdToRemove).tag;
svv.tracker.push(`Click=TagRemove_Tag="${tagToRemove}"`);
label.setProperty('newTags', label.getProperty('newTags').filter(t => t !== tagToRemove));
_renderTags();
}

function _renderTags() {
let label = svv.panorama.getCurrentLabel();
let allTagOptions = structuredClone(svv.tagsByLabelType[label.getAuditProperty('labelType')]);

menuUI.currentTags.empty();
const currTags = label.getProperty('newTags');
const currTags = label.getProperty('newTags') || [];

// Clone the template tag element, remove the 'template' class, update the text, and add the removal onclick.
for (let tag of currTags) {
if (!allTagOptions.some(t => t.tag_name === tag)) {
if (!allTagOptions.some(t => t.tag === tag)) {
continue; // Skip tags that are now being excluded on this server. Don't want to show them.
}

// Clone the template tag element, remove the 'template' class, and add a tag-id data attribute.
let $tagDiv = $('.current-tag.template').clone().removeClass('template');
$tagDiv.data('tag-id', allTagOptions.find(t => t.tag_name === tag).tag_id);
$tagDiv.data('tag-id', allTagOptions.find(t => t.tag === tag).tag_id);

// Update the tag name.
const translatedTagName = i18next.t('common:tag.' + tag.replace(/:/g, '-'));
Expand All @@ -320,13 +321,13 @@ function RightMenu(menuUI) {
$tagDiv.children('.remove-tag-x').click(e => _removeTag(e, label));

// Add an example image tooltip to the tag.
const tagId = allTagOptions.find(t => t.tag_name === tag).tag_id;
const tagId = allTagOptions.find(t => t.tag === tag).tag_id;
const tooltipText = `"${translatedTagName}" example`
_addTooltip($tagDiv, tooltipText, `/assets/images/examples/tags/${tagId}.png`);

// Add to current list of tags, and remove from options for new tags to add.
menuUI.currentTags.append($tagDiv);
allTagOptions = allTagOptions.filter(t => t.tag_name !== tag);
allTagOptions = allTagOptions.filter(t => t.tag !== tag);
}

// Show/hide elem for list of tags to hide extra spacing b/w elements when there are no tags to show.
Expand All @@ -338,7 +339,12 @@ function RightMenu(menuUI) {

// Clear the possible tags to add and add all appropriate options.
$tagSelect[0].selectize.clearOptions();
$tagSelect[0].selectize.addOption(allTagOptions);

// Sort remaining tags by popularity while grouping mutually exclusive tags
const sortedTags = util.sortTagsByPopularityAndGroupMutuallyExclusive(allTagOptions, 'count', 'tag', 'mutually_exclusive_with');

// Add the sorted tags to the selectize dropdown
$tagSelect[0].selectize.addOption(sortedTags);
}

// SEVERITY SECTION.
Expand Down
59 changes: 59 additions & 0 deletions public/javascripts/common/Utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,62 @@ function escapeHTML(str) {
});
}
util.escapeHTML = escapeHTML;

/**
* Sorts tags by their count/popularity while keeping mutually exclusive tags grouped together.
* This ensures that related tags appear next to each other in the UI.
* @param {Array} tags - The array of tags to sort
* @param {string} countField - The name of the field containing the count/popularity value (default: 'count')
* @param {string} nameField - The name of the field containing the tag name (default: 'tag_name' or 'tag')
* @param {string} mutuallyExclusiveField - The name of the field for mutually exclusive relationships (default: 'mutually_exclusive_with')
* @returns {Array} The sorted array of tags
*/
function sortTagsByPopularityAndGroupMutuallyExclusive(tags, countField, nameField, mutuallyExclusiveField) {
// Set default field names if not provided
countField = countField || 'count';
nameField = nameField || (tags[0] && tags[0].tag_name !== undefined ? 'tag_name' : 'tag');
mutuallyExclusiveField = mutuallyExclusiveField || 'mutually_exclusive_with';

// Return early if tags array is empty
if (!tags || tags.length === 0) {
return [];
}

// Group tags by mutually exclusive relationships
const tagGroups = {};
tags.forEach(tag => {
// For mutually exclusive tags, use the same group key for both tags
// Use the tag name for grouping, or create a special key for mutually exclusive pairs
const mutuallyExclusiveValue = tag[mutuallyExclusiveField];
const tagName = tag[nameField];

const groupKey = mutuallyExclusiveValue
? [tagName, mutuallyExclusiveValue].sort()[0]
: tagName;

if (!tagGroups[groupKey]) {
tagGroups[groupKey] = [];
}
tagGroups[groupKey].push(tag);
});

// Calculate max count for each mutually exclusive group
const groupMaxCounts = {};
Object.entries(tagGroups).forEach(([groupKey, tagsInGroup]) => {
groupMaxCounts[groupKey] = Math.max(...tagsInGroup.map(t => t[countField] || 0));
});

// Sort mutually exclusive groups by their max count
const sortedGroupKeys = Object.keys(tagGroups).sort((a, b) => {
return groupMaxCounts[b] - groupMaxCounts[a];
});

// Flatten the sorted groups back into a single array
const processedTags = sortedGroupKeys.flatMap(groupKey => {
// Within each group, sort by count
return tagGroups[groupKey].sort((a, b) => (b[countField] || 0) - (a[countField] || 0));
});

return processedTags;
}
util.sortTagsByPopularityAndGroupMutuallyExclusive = sortTagsByPopularityAndGroupMutuallyExclusive;