Basic implementation of advanced search
This commit is contained in:
parent
f382a0ed48
commit
9e15e6bcc0
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {ViewSimilarReplaysComponent} from "./view-similar-replays/view-similar-r
|
||||
import {ViewScoreComponent} from "./view-score/view-score.component";
|
||||
import {ViewUserComponent} from "./view-user/view-user.component";
|
||||
import {ViewReplayPairComponent} from "./view-replay-pair/view-replay-pair.component";
|
||||
import {SearchComponent} from "./search/search.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{path: 'sus/:f', component: ViewSuspiciousScoresComponent, title: '/sus/'},
|
||||
@ -16,6 +17,7 @@ const routes: Routes = [
|
||||
|
||||
{path: 'u/:userId', component: ViewUserComponent},
|
||||
{path: 's/:replayId', component: ViewScoreComponent},
|
||||
{path: 'search', component: SearchComponent},
|
||||
{path: 'p/:replay1Id/:replay2Id', component: ViewReplayPairComponent},
|
||||
|
||||
{path: '**', component: HomeComponent, title: '/nise.moe/'},
|
||||
|
||||
17
nise-frontend/src/app/search/search.component.css
Normal file
17
nise-frontend/src/app/search/search.component.css
Normal 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);
|
||||
}
|
||||
80
nise-frontend/src/app/search/search.component.html
Normal file
80
nise-frontend/src/app/search/search.component.html
Normal 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>
|
||||
214
nise-frontend/src/app/search/search.component.ts
Normal file
214
nise-frontend/src/app/search/search.component.ts
Normal 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;
|
||||
}
|
||||
@ -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 */
|
||||
}
|
||||
@ -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>
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user