Basic implementation of advanced search

This commit is contained in:
nise.moe 2024-02-24 12:16:21 +01:00
parent f382a0ed48
commit 9e15e6bcc0
8 changed files with 844 additions and 0 deletions

View File

@ -0,0 +1,391 @@
package com.nisemoe.nise.controller
import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.generated.tables.references.USERS
import com.nisemoe.nise.Format
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Field
import org.jooq.OrderField
import org.jooq.impl.DSL
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RestController
import kotlin.math.roundToInt
@RestController
class SearchController(
private val dslContext: DSLContext
) {
data class SearchResponse(
val scores: List<SearchResponseEntry>,
val totalResults: Int? = null
)
data class SearchResponseEntry(
// User fields
val user_id: Long?,
val user_username: String?,
val user_join_date: String?,
val user_country: String?,
val user_country_rank: Long?,
val user_rank: Long?,
val user_pp_raw: Double?,
val user_accuracy: Double?,
val user_playcount: Long?,
val user_total_score: Long?,
val user_ranked_score: Long?,
val user_seconds_played: Long?,
val user_count_300: Long?,
val user_count_100: Long?,
val user_count_50: Long?,
val user_count_miss: Int?,
// Score fields
val id: Int?,
val beatmap_id: Int?,
val count_300: Int?,
val count_100: Int?,
val count_50: Int?,
val count_miss: Int?,
val date: String?,
val max_combo: Int?,
val mods: Int?,
val perfect: Boolean?,
val pp: Double?,
val rank: String?,
val replay_id: Long?,
val score: Long?,
val ur: Double?,
val frametime: Double?,
val edge_hits: Int?,
val snaps: Int?,
val adjusted_ur: Double?,
val mean_error: Double?,
val error_variance: Double?,
val error_standard_deviation: Double?,
val minimum_error: Double?,
val maximum_error: Double?,
val error_range: Double?,
val error_coefficient_of_variation: Double?,
val error_kurtosis: Double?,
val error_skewness: Double?,
val keypresses_median_adjusted: Double?,
val keypresses_standard_deviation_adjusted: Double?,
val sliderend_release_median_adjusted: Double?,
val sliderend_release_standard_deviation_adjusted: Double?,
// Beatmap fields
val beatmap_artist: String?,
val beatmap_beatmapset_id: Int?,
val beatmap_creator: String?,
val beatmap_source: String?,
val beatmap_star_rating: Double?,
val beatmap_title: String?,
val beatmap_version: String?
)
data class SearchRequest(
val queries: List<SearchQuery>,
val sorting: SearchSorting
)
data class SearchSorting(
val field: String,
val order: String
)
data class SearchQuery(
val logicalOperator: String,
val predicates: List<SearchPredicate>
)
data class SearchPredicate(
val field: SearchField,
val operator: String,
val value: String
)
data class SearchField(
val name: String,
val type: String
)
@PostMapping("search")
fun doSearch(
@RequestBody request: SearchRequest,
@RequestHeader("X-NISE-API") apiVersion: String
): ResponseEntity<SearchResponse> {
if (apiVersion.isBlank())
return ResponseEntity.badRequest().build()
var baseQuery = DSL.noCondition()
for (query in request.queries.filter { it.predicates.isNotEmpty() }) {
var condition = buildCondition(query.predicates[0]) // Start with the first predicate
for (i in 1 until query.predicates.size) {
val nextPredicate = query.predicates[i]
condition = when (query.logicalOperator.lowercase()) {
"and" -> condition.and(buildCondition(nextPredicate))
"or" -> condition.or(buildCondition(nextPredicate))
else -> throw IllegalArgumentException("Invalid logical operator")
}
}
baseQuery = baseQuery.and(condition)
}
val results = dslContext.select(
// User fields
USERS.USERNAME,
USERS.USER_ID,
USERS.JOIN_DATE,
USERS.COUNTRY,
USERS.COUNTRY_RANK,
USERS.RANK,
USERS.PP_RAW,
USERS.ACCURACY,
USERS.PLAYCOUNT,
USERS.TOTAL_SCORE,
USERS.RANKED_SCORE,
USERS.SECONDS_PLAYED,
USERS.COUNT_300,
USERS.COUNT_100,
USERS.COUNT_50,
// Scores fields
SCORES.ID,
SCORES.BEATMAP_ID,
SCORES.COUNT_300,
SCORES.COUNT_100,
SCORES.COUNT_50,
SCORES.COUNT_MISS,
SCORES.DATE,
SCORES.MAX_COMBO,
SCORES.MODS,
SCORES.PERFECT,
SCORES.PP,
SCORES.RANK,
SCORES.REPLAY_ID,
SCORES.SCORE,
SCORES.UR,
SCORES.FRAMETIME,
SCORES.EDGE_HITS,
SCORES.SNAPS,
SCORES.ADJUSTED_UR,
SCORES.MEAN_ERROR,
SCORES.ERROR_VARIANCE,
SCORES.ERROR_STANDARD_DEVIATION,
SCORES.MINIMUM_ERROR,
SCORES.MAXIMUM_ERROR,
SCORES.ERROR_RANGE,
SCORES.ERROR_COEFFICIENT_OF_VARIATION,
SCORES.ERROR_KURTOSIS,
SCORES.ERROR_SKEWNESS,
SCORES.KEYPRESSES_MEDIAN_ADJUSTED,
SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED,
SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED,
SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED,
// Beatmaps fields
BEATMAPS.ARTIST,
BEATMAPS.BEATMAPSET_ID,
BEATMAPS.CREATOR,
BEATMAPS.SOURCE,
BEATMAPS.STAR_RATING,
BEATMAPS.TITLE,
BEATMAPS.VERSION
)
.from(SCORES)
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
.join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID))
.where(baseQuery)
.apply {
if (request.sorting.field.isNotBlank())
orderBy(buildSorting(request.sorting))
}
.limit(50)
.fetch()
val response = SearchResponse(
scores = results.map {
SearchResponseEntry(
// User fields
user_id = it.get(SCORES.USER_ID),
user_username = it.get(USERS.USERNAME),
user_join_date = it.get(USERS.JOIN_DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
user_country = it.get(USERS.COUNTRY),
user_country_rank = it.get(USERS.COUNTRY_RANK),
user_rank = it.get(USERS.RANK),
user_pp_raw = it.get(USERS.PP_RAW)?.roundToInt()?.toDouble(),
user_accuracy = it.get(USERS.ACCURACY),
user_playcount = it.get(USERS.PLAYCOUNT),
user_total_score = it.get(USERS.TOTAL_SCORE),
user_ranked_score = it.get(USERS.RANKED_SCORE),
user_seconds_played = it.get(USERS.SECONDS_PLAYED),
user_count_300 = it.get(USERS.COUNT_300),
user_count_100 = it.get(USERS.COUNT_100),
user_count_50 = it.get(USERS.COUNT_50),
user_count_miss = it.get(SCORES.COUNT_MISS),
// Score fields
id = it.get(SCORES.ID),
beatmap_id = it.get(SCORES.BEATMAP_ID),
count_300 = it.get(SCORES.COUNT_300),
count_100 = it.get(SCORES.COUNT_100),
count_50 = it.get(SCORES.COUNT_50),
count_miss = it.get(SCORES.COUNT_MISS),
date = it.get(SCORES.DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
max_combo = it.get(SCORES.MAX_COMBO),
mods = it.get(SCORES.MODS),
perfect = it.get(SCORES.PERFECT),
pp = it.get(SCORES.PP)?.roundToInt()?.toDouble(),
rank = it.get(SCORES.RANK),
replay_id = it.get(SCORES.REPLAY_ID),
score = it.get(SCORES.SCORE),
ur = it.get(SCORES.UR),
frametime = it.get(SCORES.FRAMETIME),
edge_hits = it.get(SCORES.EDGE_HITS),
snaps = it.get(SCORES.SNAPS),
adjusted_ur = it.get(SCORES.ADJUSTED_UR),
mean_error = it.get(SCORES.MEAN_ERROR),
error_variance = it.get(SCORES.ERROR_VARIANCE),
error_standard_deviation = it.get(SCORES.ERROR_STANDARD_DEVIATION),
minimum_error = it.get(SCORES.MINIMUM_ERROR),
maximum_error = it.get(SCORES.MAXIMUM_ERROR),
error_range = it.get(SCORES.ERROR_RANGE),
error_coefficient_of_variation = it.get(SCORES.ERROR_COEFFICIENT_OF_VARIATION),
error_kurtosis = it.get(SCORES.ERROR_KURTOSIS),
error_skewness = it.get(SCORES.ERROR_SKEWNESS),
keypresses_median_adjusted = it.get(SCORES.KEYPRESSES_MEDIAN_ADJUSTED),
keypresses_standard_deviation_adjusted = it.get(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED),
sliderend_release_median_adjusted = it.get(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED),
sliderend_release_standard_deviation_adjusted = it.get(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED),
// Beatmap fields
beatmap_artist = it.get(BEATMAPS.ARTIST),
beatmap_beatmapset_id = it.get(BEATMAPS.BEATMAPSET_ID),
beatmap_creator = it.get(BEATMAPS.CREATOR),
beatmap_source = it.get(BEATMAPS.SOURCE),
beatmap_star_rating = it.get(BEATMAPS.STAR_RATING),
beatmap_title = it.get(BEATMAPS.TITLE),
beatmap_version = it.get(BEATMAPS.VERSION)
)
}
)
return ResponseEntity.ok(response)
}
private fun buildSorting(sorting: SearchSorting): OrderField<*> {
val field = mapPredicateFieldToDatabaseField(sorting.field)
return when (sorting.order.lowercase()) {
"asc" -> field.asc()
"desc" -> field.desc()
else -> throw IllegalArgumentException("Invalid sorting order")
}
}
private fun buildCondition(predicate: SearchPredicate): Condition {
val field = mapPredicateFieldToDatabaseField(predicate.field.name)
return when (predicate.field.type.lowercase()) {
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator, predicate.value.toDouble())
"string" -> buildStringCondition(field as Field<String>, predicate.operator, predicate.value)
else -> throw IllegalArgumentException("Invalid field type")
}
}
private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> {
return when (predicateName.lowercase()) {
// User fields
"user_id" -> USERS.USER_ID
"user_username" -> USERS.USERNAME
"user_join_date" -> USERS.JOIN_DATE
"user_country" -> USERS.COUNTRY
"user_country_rank" -> USERS.COUNTRY_RANK
"user_rank" -> USERS.RANK
"user_pp_raw" -> USERS.PP_RAW
"user_accuracy" -> USERS.ACCURACY
"user_playcount" -> USERS.PLAYCOUNT
"user_total_score" -> USERS.TOTAL_SCORE
"user_ranked_score" -> USERS.RANKED_SCORE
"user_seconds_played" -> USERS.SECONDS_PLAYED
"user_count_300" -> USERS.COUNT_300
"user_count_100" -> USERS.COUNT_100
"user_count_50" -> USERS.COUNT_50
// Score fields
"id" -> SCORES.ID
"beatmap_id" -> SCORES.BEATMAP_ID
"count_300" -> SCORES.COUNT_300
"count_100" -> SCORES.COUNT_100
"count_50" -> SCORES.COUNT_50
"count_miss" -> SCORES.COUNT_MISS
"date" -> SCORES.DATE
"max_combo" -> SCORES.MAX_COMBO
"mods" -> SCORES.MODS
"perfect" -> SCORES.PERFECT
"pp" -> SCORES.PP
"rank" -> SCORES.RANK
"replay_id" -> SCORES.REPLAY_ID
"score" -> SCORES.SCORE
"ur" -> SCORES.UR
"frametime" -> SCORES.FRAMETIME
"edge_hits" -> SCORES.EDGE_HITS
"snaps" -> SCORES.SNAPS
"adjusted_ur" -> SCORES.ADJUSTED_UR
"mean_error" -> SCORES.MEAN_ERROR
"error_variance" -> SCORES.ERROR_VARIANCE
"error_standard_deviation" -> SCORES.ERROR_STANDARD_DEVIATION
"minimum_error" -> SCORES.MINIMUM_ERROR
"maximum_error" -> SCORES.MAXIMUM_ERROR
"error_range" -> SCORES.ERROR_RANGE
"error_coefficient_of_variation" -> SCORES.ERROR_COEFFICIENT_OF_VARIATION
"error_kurtosis" -> SCORES.ERROR_KURTOSIS
"error_skewness" -> SCORES.ERROR_SKEWNESS
"keypresses_median_adjusted" -> SCORES.KEYPRESSES_MEDIAN_ADJUSTED
"keypresses_standard_deviation_adjusted" -> SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED
"sliderend_release_median_adjusted" -> SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED
"sliderend_release_standard_deviation_adjusted" -> SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED
// Beatmap fields
"beatmap_artist" -> BEATMAPS.ARTIST
"beatmap_beatmapset_id" -> BEATMAPS.BEATMAPSET_ID
"beatmap_creator" -> BEATMAPS.CREATOR
"beatmap_source" -> BEATMAPS.SOURCE
"beatmap_star_rating" -> BEATMAPS.STAR_RATING
"beatmap_title" -> BEATMAPS.TITLE
"beatmap_version" -> BEATMAPS.VERSION
else -> throw IllegalArgumentException("Invalid field name: $predicateName")
}
}
private fun buildNumberCondition(field: Field<Double>, operator: String, value: Double): Condition {
return when (operator) {
"=" -> field.eq(value)
">" -> field.gt(value)
"<" -> field.lt(value)
">=" -> field.ge(value)
"<=" -> field.le(value)
"!=" -> field.ne(value)
else -> throw IllegalArgumentException("Invalid operator")
}
}
private fun buildStringCondition(field: Field<String>, operator: String, value: String): Condition {
return when (operator.lowercase()) {
"=" -> field.eq(value)
"contains" -> field.containsIgnoreCase(value)
"like" -> field.likeIgnoreCase(
// Escape special characters for LIKE if needed
value.replace("%", "\\%").replace("_", "\\_")
)
else -> throw IllegalArgumentException("Invalid operator")
}
}
}

View File

@ -6,6 +6,7 @@ import {ViewSimilarReplaysComponent} from "./view-similar-replays/view-similar-r
import {ViewScoreComponent} from "./view-score/view-score.component"; import {ViewScoreComponent} from "./view-score/view-score.component";
import {ViewUserComponent} from "./view-user/view-user.component"; import {ViewUserComponent} from "./view-user/view-user.component";
import {ViewReplayPairComponent} from "./view-replay-pair/view-replay-pair.component"; import {ViewReplayPairComponent} from "./view-replay-pair/view-replay-pair.component";
import {SearchComponent} from "./search/search.component";
const routes: Routes = [ const routes: Routes = [
{path: 'sus/:f', component: ViewSuspiciousScoresComponent, title: '/sus/'}, {path: 'sus/:f', component: ViewSuspiciousScoresComponent, title: '/sus/'},
@ -16,6 +17,7 @@ const routes: Routes = [
{path: 'u/:userId', component: ViewUserComponent}, {path: 'u/:userId', component: ViewUserComponent},
{path: 's/:replayId', component: ViewScoreComponent}, {path: 's/:replayId', component: ViewScoreComponent},
{path: 'search', component: SearchComponent},
{path: 'p/:replay1Id/:replay2Id', component: ViewReplayPairComponent}, {path: 'p/:replay1Id/:replay2Id', component: ViewReplayPairComponent},
{path: '**', component: HomeComponent, title: '/nise.moe/'}, {path: '**', component: HomeComponent, title: '/nise.moe/'},

View File

@ -0,0 +1,17 @@
.search-container {
/* your styles for the container */
}
.scrollable-table {
overflow-x: auto; /* Enables horizontal scrolling */
width: 100%; /* Adjust as needed */
}
.table-border {
border: 1px solid rgba(179, 184, 195, 0.1);
}
.table-border th, .table-border td {
padding: 2px;
border: 1px solid rgba(179, 184, 195, 0.1);
}

View File

@ -0,0 +1,80 @@
<div class="main term">
<h1><span class="board">/k/</span> - Advanced Search</h1>
<!-- <pre>{{ queries | json }}</pre>-->
<fieldset>
<legend>Table columns</legend>
<ng-container *ngFor="let category of ['user', 'beatmap', 'score']">
<fieldset>
<legend>{{ category }}</legend>
<ng-container *ngFor="let field of fields">
<div *ngIf="field.category === category">
<label>
<input type="checkbox" [(ngModel)]="field.active" (change)="this.saveColumnsStatusToLocalStorage()"/>
{{ field.name }} <span class="text-muted" style="margin-left: 6px">{{ field.description }}</span>
</label>
</div>
</ng-container>
</fieldset>
</ng-container>
</fieldset>
<div class="search-container mt-2" *ngIf="this.queries">
<app-query-builder [queries]="this.queries" [fields]="this.mapSchemaFieldsToFields()"></app-query-builder>
</div>
<fieldset class="mt-2" *ngIf="this.sortingOrder">
<legend>sorting</legend>
<select>
<option *ngFor="let field of fields" [value]="field.name" (click)="this.sortingOrder.field = field.name">{{ field.name }}</option>
</select>
<label>
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
ASC
</label>
<label>
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="DESC" />
DESC
</label>
</fieldset>
<div class="text-center">
<button (click)="search()" class="mb-2 mt-2">Search</button>
</div>
<ng-container *ngIf="response">
<div class="scrollable-table">
<table class="table-border">
<thead>
<tr>
<th *ngFor="let column of fields" [hidden]="!column.active" class="text-center">
{{ column.short_name }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let entry of response.scores">
<td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px">
<ng-container *ngIf="column.type == 'number'">
{{ getValue(entry, column.name) | number }}
</ng-container>
<ng-container *ngIf="column.type == 'flag'">
<span class="flag">{{ countryCodeToFlag(getValue(entry, column.name)) }}</span>
</ng-container>
<ng-container *ngIf="column.type == 'grade'">
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
</ng-container>
<ng-container *ngIf="column.type == 'string'">
{{ getValue(entry, column.name) }}
</ng-container>
</td>
</tr>
</tbody>
</table>
</div>
</ng-container>
</div>

View File

@ -0,0 +1,214 @@
import {Component, OnInit} from '@angular/core';
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {DecimalPipe, JsonPipe, NgForOf, NgIf} from "@angular/common";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {countryCodeToFlag} from "../format";
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
import {Field, Query, QueryBuilderComponent} from "../../corelib/components/query-builder/query-builder.component";
interface SchemaField {
name: string;
short_name: string;
category: 'user' | 'score' | 'beatmap';
type: 'number' | 'string' | 'flag' | 'grade' | 'boolean';
active: boolean;
description: string;
}
interface SearchResponse {
scores: SearchResponseEntry[];
}
interface Sorting {
field: string;
order: 'ASC' | 'DESC';
}
interface SearchResponseEntry {
// User fields
user_id?: number;
user_username?: string;
user_join_date?: string;
user_country?: string;
user_country_rank?: number;
user_rank?: number;
user_pp_raw?: number;
user_accuracy?: number;
user_playcount?: number;
user_total_score?: number;
user_ranked_score?: number;
user_seconds_played?: number;
user_count_300?: number;
user_count_100?: number;
user_count_50?: number;
user_count_miss?: number;
// Score fields
replay_id?: number;
date?: string;
beatmap_id?: number;
pp?: number;
frametime?: number;
ur?: number;
// Beatmap fields
beatmap_artist?: string;
beatmap_beatmapset_id?: number;
beatmap_creator?: string;
beatmap_source?: string;
beatmap_star_rating?: number;
beatmap_title?: string;
beatmap_version?: string;
}
@Component({
selector: 'app-search',
standalone: true,
imports: [
ReactiveFormsModule,
NgForOf,
FormsModule,
JsonPipe,
NgIf,
DecimalPipe,
OsuGradeComponent,
QueryBuilderComponent
],
templateUrl: './search.component.html',
styleUrl: './search.component.css'
})
export class SearchComponent implements OnInit {
constructor(private httpClient: HttpClient) { }
response: SearchResponse | null = null;
fields: SchemaField[] = [
// User fields
{ name: "user_id", short_name: "ID", category: "user", type: "number", active: true, description: "unique identifier for a user" },
{ name: "user_username", short_name: "Username", category: "user", type: "string", active: true, description: "user's name" },
{ name: "user_join_date", short_name: "Join Date", category: "user", type: "string", active: true, description: "when the user joined" },
{ name: "user_country", short_name: "Country", category: "user", type: "flag", active: true, description: "user's country flag" },
{ name: "user_country_rank", short_name: "Country Rank", category: "user", type: "number", active: true, description: "ranking within user's country" },
{ name: "user_rank", short_name: "Rank", category: "user", type: "number", active: true, description: "global ranking" },
{ name: "user_pp_raw", short_name: "User PP", category: "user", type: "number", active: true, description: "performance points" },
{ name: "user_accuracy", short_name: "User Accuracy", category: "user", type: "number", active: true, description: "hit accuracy percentage" },
{ name: "user_playcount", short_name: "Playcount", category: "user", type: "number", active: true, description: "total plays" },
{ name: "user_total_score", short_name: "Total Score", category: "user", type: "number", active: true, description: "cumulative score" },
{ name: "user_ranked_score", short_name: "Ranked Score", category: "user", type: "number", active: true, description: "score from ranked maps" },
{ name: "user_seconds_played", short_name: "Play Time", category: "user", type: "number", active: true, description: "total play time in seconds" },
{ name: "user_count_300", short_name: "300s", category: "user", type: "number", active: true, description: "number of 300 hits" },
{ name: "user_count_100", short_name: "100s", category: "user", type: "number", active: true, description: "number of 100 hits" },
{ name: "user_count_50", short_name: "50s", category: "user", type: "number", active: true, description: "number of 50 hits" },
{ name: "user_count_miss", short_name: "Misses", category: "user", type: "number", active: true, description: "missed hits" },
// Score fields
{ name: "beatmap_id", short_name: "Beatmap ID", category: "score", type: "number", active: true, description: "identifies the beatmap" },
{ name: "count_300", short_name: "300s", category: "score", type: "number", active: true, description: "number of 300 hits in score" },
{ name: "count_100", short_name: "100s", category: "score", type: "number", active: true, description: "number of 100 hits in score" },
{ name: "count_50", short_name: "50s", category: "score", type: "number", active: true, description: "number of 50 hits in score" },
{ name: "count_miss", short_name: "Misses", category: "score", type: "number", active: true, description: "missed hits in score" },
{ name: "date", short_name: "Date", category: "score", type: "string", active: true, description: "when score was achieved" },
{ name: "max_combo", short_name: "Max Combo", category: "score", type: "number", active: true, description: "highest combo in score" },
{ name: "mods", short_name: "Mods", category: "score", type: "number", active: true, description: "game modifiers used" },
{ name: "perfect", short_name: "Perfect", category: "score", type: "boolean", active: true, description: "if score is a full combo" },
{ name: "pp", short_name: "Score PP", category: "score", type: "number", active: true, description: "performance points for score" },
{ name: "rank", short_name: "Rank", category: "score", type: "grade", active: true, description: "score grade" },
{ name: "replay_id", short_name: "Replay ID", category: "score", type: "number", active: true, description: "identifier for replay" },
{ name: "score", short_name: "Score", category: "score", type: "number", active: true, description: "score value" },
{ name: "ur", short_name: "UR", category: "score", type: "number", active: true, description: "unstable rate" },
{ name: "frametime", short_name: "Frame Time", category: "score", type: "number", active: true, description: "average frame time during play" },
{ name: "edge_hits", short_name: "Edge Hits", category: "score", type: "number", active: true, description: "hits at the edge of hit window" },
{ name: "snaps", short_name: "Snaps", category: "score", type: "number", active: true, description: "rapid cursor movements" },
{ name: "adjusted_ur", short_name: "Adj. UR", category: "score", type: "number", active: true, description: "adjusted unstable rate" },
{ name: "mean_error", short_name: "Mean Error", category: "score", type: "number", active: true, description: "average timing error" },
{ name: 'error_variance', short_name: 'Error Var.', category: 'score', type: 'number', active: true, description: 'variability of error in scores' },
{ name: 'error_standard_deviation', short_name: 'Error SD', category: 'score', type: 'number', active: true, description: 'standard deviation of error' },
{ name: 'minimum_error', short_name: 'Min Error', category: 'score', type: 'number', active: true, description: 'smallest error recorded' },
{ name: 'maximum_error', short_name: 'Max Error', category: 'score', type: 'number', active: true, description: 'largest error recorded' },
{ name: 'error_range', short_name: 'Error Range', category: 'score', type: 'number', active: true, description: 'range between min and max error' },
{ name: 'error_coefficient_of_variation', short_name: 'Error CV', category: 'score', type: 'number', active: true, description: 'relative variability of error' },
{ name: 'error_kurtosis', short_name: 'Kurtosis', category: 'score', type: 'number', active: true, description: 'peakedness of error distribution' },
{ name: 'error_skewness', short_name: 'Skewness', category: 'score', type: 'number', active: true, description: 'asymmetry of error distribution' },
{ name: 'keypresses_median_adjusted', short_name: 'KP Median Adj.', category: 'score', type: 'number', active: true, description: 'median of adjusted keypresses' },
{ name: 'keypresses_standard_deviation_adjusted', short_name: 'KP std. Adj.', category: 'score', type: 'number', active: true, description: 'std. dev of adjusted keypresses' },
{ name: 'sliderend_release_median_adjusted', short_name: 'Sliderend Median Adj.', category: 'score', type: 'number', active: true, description: 'median of adjusted sliderend releases' },
{ name: 'sliderend_release_standard_deviation_adjusted', short_name: 'Sliderend std. Adj.', category: 'score', type: 'number', active: true, description: 'std. dev of adjusted sliderend releases' },
// Beatmap fields
{ name: 'beatmap_artist', short_name: 'Artist', category: 'beatmap', type: 'string', active: true, description: 'artist of the beatmap' },
{ name: 'beatmap_beatmapset_id', short_name: 'Set ID', category: 'beatmap', type: 'number', active: true, description: 'id of the beatmap set' },
{ name: 'beatmap_creator', short_name: 'Creator', category: 'beatmap', type: 'string', active: true, description: 'creator of the beatmap' },
{ name: 'beatmap_source', short_name: 'Source', category: 'beatmap', type: 'string', active: true, description: 'source of the beatmap music' },
{ name: 'beatmap_star_rating', short_name: 'Stars', category: 'beatmap', type: 'number', active: true, description: '(★) difficulty rating of the beatmap' },
{ name: 'beatmap_title', short_name: 'Title', category: 'beatmap', type: 'string', active: true, description: 'title of the beatmap' },
{ name: 'beatmap_version', short_name: 'Version', category: 'beatmap', type: 'string', active: true, description: 'version or difficulty name of the beatmap' }
];
sortingOrder: Sorting | null = null;
queries: Query[] | null = null;
ngOnInit(): void {
const storedQueries = localStorage.getItem('search_queries');
if (storedQueries) {
this.queries = JSON.parse(storedQueries);
} else {
this.queries = [];
}
// Load active/inactive status from localStorage
const storedStatus = localStorage.getItem('columns_status');
if (storedStatus) {
const statusMap = JSON.parse(storedStatus);
this.fields.forEach(field => {
if (statusMap.hasOwnProperty(field.name)) {
field.active = statusMap[field.name];
}
});
}
this.sortingOrder = {
field: 'user_id',
order: 'ASC'
};
}
mapSchemaFieldsToFields(): Field[] {
return this.fields.map(field => {
return {
name: field.name,
type: field.type,
};
});
}
saveColumnsStatusToLocalStorage(): void {
const statusMap = this.fields.reduce<{ [key: string]: boolean }>((acc, field) => {
acc[field.name] = field.active;
return acc;
}, {});
localStorage.setItem('columns_status', JSON.stringify(statusMap));
}
search(): void {
const body = {
queries: this.queries,
sorting: this.sortingOrder
}
this.httpClient.post<SearchResponse>(`${environment.apiUrl}/search`, body).subscribe(response => {
this.response = response;
});
// this.updateLocalStorage();
}
// Add this method to the SearchComponent class
getValue(entry: SearchResponseEntry, columnName: string): any {
return entry[columnName as keyof SearchResponseEntry];
}
protected readonly countryCodeToFlag = countryCodeToFlag;
}

View File

@ -0,0 +1,26 @@
.query {
padding: 10px;
margin-bottom: 10px;
}
.logical-operator-toggle button {
/* your styles for the logical operator buttons */
}
.logical-operator-toggle button.active {
/* your styles for the active state */
}
.predicate {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.predicate select, .predicate input, .predicate button {
margin-right: 5px;
}
.predicate button {
/* style it to look more like a close button */
}

View File

@ -0,0 +1,33 @@
<fieldset>
<legend>Query builder</legend>
<fieldset *ngFor="let query of queries; let i = index" class="query">
<legend>Predicate #{{ i + 1 }}</legend>
<div class="logical-operator-toggle">
<label>
<input type="radio" name="logicalOperator{{i}}" [(ngModel)]="query.logicalOperator" value="AND" />
AND
</label>
<label>
<input type="radio" name="logicalOperator{{i}}" [(ngModel)]="query.logicalOperator" value="OR" />
OR
</label>
</div>
<div *ngFor="let predicate of query.predicates; let j = index" class="predicate">
<select>
<option *ngFor="let field of fields" (click)="onFieldChange(predicate, field)" [selected]="field.name === predicate.field?.name">{{ field.name }}</option>
</select>
<select [(ngModel)]="predicate.operator" [disabled]="!predicate.field">
<option *ngFor="let operator of getOperators(predicate.field?.type)" [value]="operator">
{{ operator }}
</option>
</select>
<input [(ngModel)]="predicate.value" type="text" placeholder="Value" [disabled]="!predicate.field">
<button (click)="removePredicate(i, j)">X</button>
</div>
<button (click)="addPredicate(i)">+ Rule</button>
</fieldset>
<button (click)="addQuery()">+ Predicate</button>
</fieldset>

View File

@ -0,0 +1,81 @@
import {Component, Input} from '@angular/core';
import {FormsModule} from "@angular/forms";
import {NgForOf, NgIf} from "@angular/common";
export type FieldType = 'number' | 'string' | 'flag' | 'grade' | 'boolean';
export type OperatorType = '=' | '>' | '<' | 'contains' | 'like' | '>=' | '<=' | '!=';
export interface Field {
name: string;
type: FieldType;
}
export interface Predicate {
field: Field | null;
operator: OperatorType | null;
value: string | number | null;
}
export interface Query {
predicates: Predicate[];
logicalOperator: 'AND' | 'OR';
}
@Component({
selector: 'app-query-builder',
standalone: true,
imports: [
FormsModule,
NgForOf,
NgIf
],
templateUrl: './query-builder.component.html',
styleUrl: './query-builder.component.css'
})
export class QueryBuilderComponent {
@Input() queries: Query[] = [];
@Input() fields: Field[] = [];
addPredicate(queryIndex: number): void {
const newPredicate: Predicate = { field: null, operator: null, value: null};
this.queries[queryIndex].predicates.push(newPredicate);
}
onFieldChange(predicate: Predicate, selectedField: any): void {
predicate.field = selectedField;
predicate.operator = this.getOperators(selectedField.type)[0];
}
getOperators(fieldType: FieldType | undefined): OperatorType[] {
switch (fieldType) {
case 'number':
return ['=', '>', '<', '>=', '<=', '!='];
case 'string':
return ['=', 'contains', 'like'];
default:
return [];
}
}
removePredicate(queryIndex: number, predicateIndex: number): void {
if(!this.queries) {
return;
}
this.queries[queryIndex].predicates.splice(predicateIndex, 1);
}
addQuery(): void {
this.queries.push({
predicates: [],
logicalOperator: 'AND'
});
}
private updateLocalStorage(): void {
console.warn('Updating local storage');
localStorage.setItem('search_queries', JSON.stringify(this.queries));
}
}