diff --git a/assets/controllers/game_config_controller.js b/assets/controllers/game_config_controller.js index 90b3c65..0ed4e3f 100644 --- a/assets/controllers/game_config_controller.js +++ b/assets/controllers/game_config_controller.js @@ -1,7 +1,26 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { - static targets = ['hintType', 'awardSection', 'allAwards', 'awardType']; + static values = { + bucketSize: { type: Number, default: 20 }, + }; + + static targets = [ + 'hintType', + 'awardSection', + 'allAwards', + 'awardType', + 'popularityMinInput', + 'popularityMaxInput', + 'popularityValue', + 'popularityFill', + 'popularitySummary', + ]; + + connect() { + this.toggleAwardSection(); + this.syncPopularityRange(); + } enforceMinOneChecked(event) { const checked = this.hintTypeTargets.filter((e) => e.checked); @@ -30,4 +49,64 @@ export default class extends Controller { const allChecked = this.awardTypeTargets.every((el) => el.checked); this.allAwardsTarget.checked = allChecked; } + + syncPopularityRange(event) { + if ( + !this.hasPopularityMinInputTarget + || !this.hasPopularityMaxInputTarget + || !this.hasPopularityValueTarget + ) { + return; + } + + let min = Number(this.popularityMinInputTarget.value); + let max = Number(this.popularityMaxInputTarget.value); + + if (event?.target === this.popularityMinInputTarget && min > max) { + max = min; + this.popularityMaxInputTarget.value = String(max); + } else if (event?.target === this.popularityMaxInputTarget && max < min) { + min = max; + this.popularityMinInputTarget.value = String(min); + } else if (min > max) { + [min, max] = [max, min]; + this.popularityMinInputTarget.value = String(min); + this.popularityMaxInputTarget.value = String(max); + } + + this.popularityValueTarget.textContent = `${min}-${max}`; + + if (this.hasPopularityFillTarget) { + const left = ((min - 1) / 9) * 100; + const right = ((10 - max) / 9) * 100; + this.popularityFillTarget.style.left = `${left}%`; + this.popularityFillTarget.style.right = `${right}%`; + } + + if (this.hasPopularitySummaryTarget) { + this.popularitySummaryTarget.textContent = + this.buildPopularitySummary(min, max); + } + } + + buildPopularitySummary(min, max) { + const bucketSize = this.bucketSizeValue; + + if (min === 1 && max === 10) { + return 'Tous les acteurs.'; + } + + const startRank = ((10 - max) * bucketSize) + 1; + const endRank = min === 1 ? null : ((10 - min + 1) * bucketSize); + + if (max === 10) { + return `Top ${(11 - min) * bucketSize} des acteurs les plus populaires.`; + } + + if (endRank === null) { + return `A partir du rang ${startRank} en popularite.`; + } + + return `Rangs ${startRank} a ${endRank} en popularite.`; + } } diff --git a/assets/styles/app.css b/assets/styles/app.css index 2f5382c..f90dd5f 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -788,6 +788,128 @@ body { margin: 10px 0 6px; } +.config-range { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.config-range-label { + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +.config-range-value { + min-width: 28px; + padding: 3px 8px; + border-radius: 999px; + background: var(--surface-tint); + color: var(--orange); + font-size: 13px; + font-weight: 700; + text-align: center; +} + +.config-range-input { + width: 100%; + accent-color: var(--orange); +} + +.config-dual-range { + position: relative; + height: 32px; + display: flex; + align-items: center; +} + +.config-dual-range-track, +.config-dual-range-fill { + position: absolute; + left: 0; + right: 0; + height: 6px; + border-radius: 999px; +} + +.config-dual-range-track { + background: var(--border); +} + +.config-dual-range-fill { + background: linear-gradient(90deg, var(--orange-light), var(--orange)); +} + +.config-dual-range .config-range-input { + position: absolute; + inset: 0; + margin: 0; + background: none; + pointer-events: none; + -webkit-appearance: none; + appearance: none; +} + +.config-dual-range .config-range-input::-webkit-slider-runnable-track { + height: 6px; + background: transparent; +} + +.config-dual-range .config-range-input::-moz-range-track { + height: 6px; + background: transparent; +} + +.config-dual-range .config-range-input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + margin-top: -6px; + border: 2px solid var(--orange); + border-radius: 50%; + background: var(--surface); + box-shadow: 0 2px 8px var(--shadow-warm); + cursor: pointer; + pointer-events: auto; +} + +.config-dual-range .config-range-input::-moz-range-thumb { + width: 18px; + height: 18px; + border: 2px solid var(--orange); + border-radius: 50%; + background: var(--surface); + box-shadow: 0 2px 8px var(--shadow-warm); + cursor: pointer; + pointer-events: auto; +} + +.config-range-input-min { + z-index: 2; +} + +.config-range-input-max { + z-index: 3; +} + +.config-range-scale { + display: flex; + justify-content: space-between; + margin-top: 6px; + font-size: 11px; + color: var(--text-faint); +} + +.config-help { + margin: 10px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} + /* Toggle switch */ .config-toggle { diff --git a/src/Controller/GameController.php b/src/Controller/GameController.php index c818c57..86fa3b0 100644 --- a/src/Controller/GameController.php +++ b/src/Controller/GameController.php @@ -16,6 +16,11 @@ use Symfony\Component\Routing\Attribute\Route; class GameController extends AbstractController { + private const int DEFAULT_MIN_POPULARITY_BUCKET = 1; + private const int DEFAULT_MAX_POPULARITY_BUCKET = 10; + private const int MIN_POPULARITY_BUCKET = 1; + private const int MAX_POPULARITY_BUCKET = 10; + #[Route('/game/start', name: 'app_game_start', methods: ['POST'])] public function start( Request $request, @@ -50,6 +55,8 @@ class GameController extends AbstractController $config['watchedOnly'] = true; } + $config['popularityRange'] = $this->extractPopularityRange($request); + $hintTypes = []; if ($request->request->getBoolean('hint_film', true)) { $hintTypes[] = 'film'; @@ -126,4 +133,36 @@ class GameController extends AbstractController throw $this->createAccessDeniedException('Invalid CSRF token.'); } } + + /** + * @return array{minBucket: int, maxBucket: int} + */ + private function extractPopularityRange(Request $request): array + { + $minBucket = $this->extractPopularityBucket($request->request->get('popularity_min_bucket'), self::DEFAULT_MIN_POPULARITY_BUCKET); + $maxBucket = $this->extractPopularityBucket($request->request->get('popularity_max_bucket'), self::DEFAULT_MAX_POPULARITY_BUCKET); + + if ($minBucket > $maxBucket) { + [$minBucket, $maxBucket] = [$maxBucket, $minBucket]; + } + + return [ + 'minBucket' => $minBucket, + 'maxBucket' => $maxBucket, + ]; + } + + private function extractPopularityBucket(mixed $rawValue, int $default): int + { + if (!is_scalar($rawValue) || $rawValue === '') { + return $default; + } + + $bucket = filter_var((string) $rawValue, FILTER_VALIDATE_INT); + if ($bucket === false) { + return $default; + } + + return max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $bucket)); + } } diff --git a/src/Provider/GameGridProvider.php b/src/Provider/GameGridProvider.php index 0c59d24..b3878fc 100644 --- a/src/Provider/GameGridProvider.php +++ b/src/Provider/GameGridProvider.php @@ -16,6 +16,9 @@ use Doctrine\ORM\EntityManagerInterface; class GameGridProvider { + private const int DEFAULT_MIN_POPULARITY_BUCKET = 1; + private const int DEFAULT_MAX_POPULARITY_BUCKET = 10; + public function __construct( private readonly ActorRepository $actorRepository, private readonly MovieRoleRepository $movieRoleRepository, @@ -25,16 +28,19 @@ class GameGridProvider ) {} /** - * @param array{watchedOnly?: bool, hintTypes?: list, awardTypeIds?: list|null} $config + * @param array{watchedOnly?: bool, popularityRange?: array{minBucket?: int, maxBucket?: int}, hintTypes?: list, awardTypeIds?: list|null} $config */ public function generate(?User $user = null, array $config = []): ?Game { $watchedOnly = $config['watchedOnly'] ?? false; + $popularityRange = $config['popularityRange'] ?? []; + $minPopularityBucket = $popularityRange['minBucket'] ?? self::DEFAULT_MIN_POPULARITY_BUCKET; + $maxPopularityBucket = $popularityRange['maxBucket'] ?? self::DEFAULT_MAX_POPULARITY_BUCKET; $hintTypes = $config['hintTypes'] ?? ['film', 'character', 'award']; $awardTypeIds = $config['awardTypeIds'] ?? null; for ($attempt = 0; $attempt < 5; $attempt++) { - $game = $this->tryGenerate($user, $watchedOnly, $hintTypes, $awardTypeIds); + $game = $this->tryGenerate($user, $watchedOnly, (int) $minPopularityBucket, (int) $maxPopularityBucket, $hintTypes, $awardTypeIds); if ($game !== null) { return $game; } @@ -47,12 +53,12 @@ class GameGridProvider * @param list $hintTypes * @param list|null $awardTypeIds */ - private function tryGenerate(?User $user, bool $watchedOnly, array $hintTypes, ?array $awardTypeIds): ?Game + private function tryGenerate(?User $user, bool $watchedOnly, int $minPopularityBucket, int $maxPopularityBucket, array $hintTypes, ?array $awardTypeIds): ?Game { if ($watchedOnly && $user !== null) { - $mainActor = $this->actorRepository->findOneRandomInWatchedFilms($user, 4); + $mainActor = $this->actorRepository->findOneRandomInWatchedFilmsByPopularityBucketRange($user, $minPopularityBucket, $maxPopularityBucket); } else { - $mainActor = $this->actorRepository->findOneRandom(4); + $mainActor = $this->actorRepository->findOneRandomByPopularityBucketRange($minPopularityBucket, $maxPopularityBucket); } if ($mainActor === null) { @@ -74,9 +80,9 @@ class GameGridProvider $actor = null; for ($try = 0; $try < 5; $try++) { if ($watchedOnly && $user !== null) { - $candidate = $this->actorRepository->findOneRandomInWatchedFilms($user, 4, $char); + $candidate = $this->actorRepository->findOneRandomInWatchedFilmsByPopularityBucketRange($user, $minPopularityBucket, $maxPopularityBucket, $char); } else { - $candidate = $this->actorRepository->findOneRandom(4, $char); + $candidate = $this->actorRepository->findOneRandomByPopularityBucketRange($minPopularityBucket, $maxPopularityBucket, $char); } if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) { diff --git a/src/Repository/ActorRepository.php b/src/Repository/ActorRepository.php index 793c9c7..68f2eb8 100644 --- a/src/Repository/ActorRepository.php +++ b/src/Repository/ActorRepository.php @@ -13,19 +13,76 @@ use Doctrine\Persistence\ManagerRegistry; */ class ActorRepository extends ServiceEntityRepository { + private const int MIN_POPULARITY_BUCKET = 1; + private const int MAX_POPULARITY_BUCKET = 10; + private const int POPULARITY_BUCKET_SIZE = 20; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Actor::class); } - public function findOneRandom(?float $popularity = null, ?string $char = null): Actor + public function findOneRandomByPopularityBucketRange(int $minBucket, int $maxBucket, ?string $char = null): ?Actor + { + $actorId = $this->findRandomActorIdByPopularityBucketRange( + <<<'SQL' + SELECT a.id, a.name, a.popularity + FROM actor a + WHERE a.popularity IS NOT NULL + SQL, + [], + $minBucket, + $maxBucket, + $char, + ); + + if ($actorId === null) { + return null; + } + + $actor = $this->find($actorId); + + return $actor instanceof Actor ? $actor : null; + } + + public function findOneRandomInWatchedFilmsByPopularityBucketRange(User $user, int $minBucket, int $maxBucket, ?string $char = null): ?Actor + { + $actorId = $this->findRandomActorIdByPopularityBucketRange( + <<<'SQL' + SELECT DISTINCT a.id, a.name, a.popularity + FROM actor a + JOIN movie_role mr ON mr.actor_id = a.id + JOIN user_movie um ON um.movie_id = mr.movie_id AND um.user_id = :userId + WHERE a.popularity IS NOT NULL + SQL, + ['userId' => $user->getId()], + $minBucket, + $maxBucket, + $char, + ); + + if ($actorId === null) { + return null; + } + + $actor = $this->find($actorId); + + return $actor instanceof Actor ? $actor : null; + } + + public function findOneRandom(?float $minPopularity = null, ?float $maxPopularity = null, ?string $char = null): ?Actor { $qb = $this->createQueryBuilder('o'); $expr = $qb->expr(); - if (!empty($popularity)) { - $qb->andWhere($expr->gte('o.popularity', ':popularity')) - ->setParameter('popularity', $popularity); + if (null !== $minPopularity) { + $qb->andWhere($expr->gte('o.popularity', ':minPopularity')) + ->setParameter('minPopularity', $minPopularity); + } + + if (null !== $maxPopularity) { + $qb->andWhere($expr->lte('o.popularity', ':maxPopularity')) + ->setParameter('maxPopularity', $maxPopularity); } if (!empty($char)) { @@ -41,7 +98,7 @@ class ActorRepository extends ServiceEntityRepository ; } - public function findOneRandomInWatchedFilms(User $user, ?float $popularity = null, ?string $char = null): ?Actor + public function findOneRandomInWatchedFilms(User $user, ?float $minPopularity = null, ?float $maxPopularity = null, ?string $char = null): ?Actor { $qb = $this->createQueryBuilder('a') ->join('a.movieRoles', 'mr') @@ -49,9 +106,14 @@ class ActorRepository extends ServiceEntityRepository ->join(UserMovie::class, 'um', 'WITH', 'um.movie = m AND um.user = :user') ->setParameter('user', $user); - if (!empty($popularity)) { - $qb->andWhere('a.popularity >= :popularity') - ->setParameter('popularity', $popularity); + if (null !== $minPopularity) { + $qb->andWhere('a.popularity >= :minPopularity') + ->setParameter('minPopularity', $minPopularity); + } + + if (null !== $maxPopularity) { + $qb->andWhere('a.popularity <= :maxPopularity') + ->setParameter('maxPopularity', $maxPopularity); } if (!empty($char)) { @@ -65,4 +127,82 @@ class ActorRepository extends ServiceEntityRepository ->getQuery() ->getOneOrNullResult(); } + + private function findRandomActorIdByPopularityBucketRange( + string $baseSql, + array $params, + int $minBucket, + int $maxBucket, + ?string $char = null, + ): ?int + { + [$minBucket, $maxBucket] = $this->normalizePopularityBuckets($minBucket, $maxBucket); + [$startRank, $endRank] = $this->resolvePopularityRankBounds($minBucket, $maxBucket); + + $sql = <<<'SQL' + WITH ranked AS ( + SELECT + base.id, + base.name, + ROW_NUMBER() OVER (ORDER BY base.popularity DESC, base.id DESC) AS popularity_rank + FROM ( + SQL; + $sql .= "\n" . $baseSql . "\n"; + $sql .= <<<'SQL' + ) AS base + ) + SELECT ranked.id + FROM ranked + WHERE ranked.popularity_rank >= :startRank + SQL; + + $queryParams = [ + ...$params, + 'startRank' => $startRank, + ]; + + if ($endRank !== null) { + $sql .= "\nAND ranked.popularity_rank <= :endRank"; + $queryParams['endRank'] = $endRank; + } + + if ($char !== null && $char !== '') { + $sql .= "\nAND LOWER(ranked.name) LIKE LOWER(:name)"; + $queryParams['name'] = '%' . $char . '%'; + } + + $sql .= "\nORDER BY RANDOM()\nLIMIT 1"; + + $result = $this->getEntityManager()->getConnection()->fetchOne($sql, $queryParams); + + return $result === false ? null : (int) $result; + } + + /** + * @return array{0: int, 1: int} + */ + private function normalizePopularityBuckets(int $minBucket, int $maxBucket): array + { + $minBucket = max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $minBucket)); + $maxBucket = max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $maxBucket)); + + if ($minBucket > $maxBucket) { + [$minBucket, $maxBucket] = [$maxBucket, $minBucket]; + } + + return [$minBucket, $maxBucket]; + } + + /** + * @return array{0: int, 1: ?int} + */ + private function resolvePopularityRankBounds(int $minBucket, int $maxBucket): array + { + $startRank = ((self::MAX_POPULARITY_BUCKET - $maxBucket) * self::POPULARITY_BUCKET_SIZE) + 1; + $endRank = $minBucket === self::MIN_POPULARITY_BUCKET + ? null + : ((self::MAX_POPULARITY_BUCKET - $minBucket + 1) * self::POPULARITY_BUCKET_SIZE); + + return [$startRank, $endRank]; + } } diff --git a/templates/homepage/index.html.twig b/templates/homepage/index.html.twig index 846703b..a0d2316 100644 --- a/templates/homepage/index.html.twig +++ b/templates/homepage/index.html.twig @@ -65,10 +65,55 @@ {% else %}
+ data-controller="game-config" + data-game-config-bucket-size-value="20">
+
+
Acteurs
+
+ Tranche de popularité + 1-10 +
+
+
+
+ + +
+ +

+ Tous les acteurs. +

+
+ {% if app.user %}