// ==UserScript==
// @name Twitch Poké Ball Helper (Enhanced UI – Browse & Advanced)
// @namespace http://tampermonkey.net/
// @version 10
// @description Twitch Poké Ball Helper with a three-column grid for Catch/Shop plus two distinct lookup tabs: a visually rich Browse tab and a detailed Advanced tab featuring a full-width Pokémon info card with Pokédex entry and evolution chain. All styled with advanced UI techniques and a unified Roboto font. Shoutout doubleupmafia @doubleupmolly @doubleuplowlow219 @doubleupap @doubleupeazy @musiclov3r1435
// @author
// @match https://www.twitch.tv/*
// @icon https://static.twitchcdn.net/assets/favicon-32-e29e246c157142c94346.png
// @grant none
// ==/UserScript==
(function () {
'use strict';
class PokeballHelper {
constructor() {
this.catchBalls = {
dollars: { command: '$', tooltip: 'Poke Dollars', image: 'https://cdn.discordapp.com/attachments/1095453488684744786/1344388523750457354/pngwing.com_13.png?ex=67c0bae1&is=67bf6961&hm=ef378cc914ec3d785094e9a21690c377fbc3d5187ee243ae0d9d21b522ece867&' },
check: { command: '!pokecheck', tooltip: 'Poke Check', image: 'https://cdn.discordapp.com/attachments/1095453488684744786/1344383577323995168/pngwing.com_2.png?ex=67c0b646&is=67bf64c6&hm=71bdf7b7a547df375849fa3874370067476fad556422dd16df77d6beba710a90&' },
poke: { command: '!pokecatch', tooltip: 'Poke Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/poke_ball.png' },
great: { command: '!pokecatch greatball', tooltip: 'Great Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/great_ball.png' },
ultra: { command: '!pokecatch ultraball', tooltip: 'Ultra Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/ultra_ball.png' },
master: { command: '!pokecatch masterball', tooltip: 'Master Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/master_ball.png' },
premier: { command: '!pokecatch premierball', tooltip: 'Premier Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/premier_ball.png' },
cherish: { command: '!pokecatch cherishball', tooltip: 'Cherish Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/cherish_ball.png' },
greatCherish: { command: '!pokecatch greatcherishball', tooltip: 'Great Cherish Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/great_cherish_ball.png' },
ultraCherish: { command: '!pokecatch ultracherishball', tooltip: 'Ultra Cherish Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/ultra_cherish_ball.png' },
heavy: { command: '!pokecatch heavyball', tooltip: 'Heavy Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/heavy_ball.png' },
feather: { command: '!pokecatch featherball', tooltip: 'Feather Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/feather_ball.png' },
timer: { command: '!pokecatch timerball', tooltip: 'Timer Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/timer_ball.png' },
quick: { command: '!pokecatch quickball', tooltip: 'Quick Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/quick_ball.png' },
nest: { command: '!pokecatch nestball', tooltip: 'Nest Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/nest_ball.png' },
fast: { command: '!pokecatch fastball', tooltip: 'Fast Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/fast_ball.png' },
heal: { command: '!pokecatch healball', tooltip: 'Heal Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/heal_ball.png' },
repeat: { command: '!pokecatch repeatball', tooltip: 'Repeat Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/repeat_ball.png' },
friend: { command: '!pokecatch friendball', tooltip: 'Friend Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/friend_ball.png' },
frozen: { command: '!pokecatch frozenball', tooltip: 'Frozen Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/frozen_ball.png' },
night: { command: '!pokecatch nightball', tooltip: 'Night Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/night_ball.png' },
phantom: { command: '!pokecatch phantomball', tooltip: 'Phantom Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/phantom_ball.png' },
cipher: { command: '!pokecatch cipherball', tooltip: 'Cipher Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/cipher_ball.png' },
magnet: { command: '!pokecatch magnetball', tooltip: 'Magnet Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/magnet_ball.png' },
net: { command: '!pokecatch netball', tooltip: 'Net Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/net_ball.png' },
luxury: { command: '!pokecatch luxuryball', tooltip: 'Luxury Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/luxury_ball.png' },
stone: { command: '!pokecatch stoneball', tooltip: 'Stone Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/stone_ball.png' },
level: { command: '!pokecatch levelball', tooltip: 'Level Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/level_ball.png' },
clone: { command: '!pokecatch cloneball', tooltip: 'Clone Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/clone_ball.png' },
sun: { command: '!pokecatch sunball', tooltip: 'Sun Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/sun_ball.png' },
fantasy: { command: '!pokecatch fantasyball', tooltip: 'Fantasy Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/fantasy_ball.png' },
mach: { command: '!pokecatch machball', tooltip: 'Mach Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/mach_ball.png' },
dive: { command: '!pokecatch diveball', tooltip: 'Dive Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/dive_ball.png' }
};
this.shopBalls = {
pokeball: { command: '!pokeshop pokeball', tooltip: 'Poke Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/poke_ball.png' },
great: { command: '!pokeshop greatball', tooltip: 'Great Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/great_ball.png' },
ultra: { command: '!pokeshop ultraball', tooltip: 'Ultra Ball', image: 'https://poketwitch.bframework.de/static/twitchextension/items/ball/ultra_ball.png' }
};
this.currentTab = 'catch';
this.allPokemonList = null;
this.isDragging = false;
this.startX = 0;
this.startY = 0;
this.containerStartLeft = 0;
this.containerStartTop = 0;
this.wasDragging = false;
this.dragStart = this.dragStart.bind(this);
this.drag = this.drag.bind(this);
this.dragEnd = this.dragEnd.bind(this);
this.init();
}
init() {
this.setupStyles();
this.waitForChat().then(() => {
this.createInterface();
this.createTimerElement();
this.addEventListeners();
this.renderGrid();
this.updateSpawnTimer();
});
}
loadPosition() {
const savedPos = localStorage.getItem('pballPosition');
if (savedPos) {
const { x, y } = JSON.parse(savedPos);
this.container.style.left = `${x}px`;
this.container.style.top = `${y}px`;
}
}
dragStart(e) {
e.preventDefault();
this.wasDragging = false;
const startX = e.clientX;
const startY = e.clientY;
const rect = this.container.getBoundingClientRect();
const origLeft = rect.left;
const origTop = rect.top;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
this.wasDragging = true;
}
this.container.style.left = `${origLeft + deltaX}px`;
this.container.style.top = `${origTop + deltaY}px`;
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
const ballImg = e.target.closest('.pball-item img');
if (ballImg) {
ballImg.style.cursor = 'grab';
}
};
const ballImg = e.target.closest('.pball-item img');
if (ballImg) {
ballImg.style.cursor = 'grabbing';
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
drag(e) {
this.container.classList.remove('dragging');
e.preventDefault();
const dx = e.clientX - this.startX;
const dy = e.clientY - this.startY;
if (!this.isDragging && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
this.isDragging = true;
}
if (this.isDragging) {
let newX = this.containerStartLeft + dx;
let newY = this.containerStartTop + dy;
const chatWindow = document.querySelector('.chat-window');
if (chatWindow) {
const chatRect = chatWindow.getBoundingClientRect();
const ballRect = this.container.getBoundingClientRect();
newX = Math.max(chatRect.left, Math.min(newX, chatRect.right - ballRect.width));
newY = Math.max(chatRect.top, Math.min(newY, chatRect.bottom - ballRect.height));
}
requestAnimationFrame(() => {
this.container.style.left = `${newX}px`;
this.container.style.top = `${newY}px`;
});
}
}
dragEnd(e) {
document.removeEventListener('mousemove', this.drag);
document.removeEventListener('mouseup', this.dragEnd);
if (this.isDragging) {
this.wasDragging = true;
const left = this.container.offsetLeft;
const top = this.container.offsetTop;
localStorage.setItem('pballPosition', JSON.stringify({ x: left, y: top }));
}
this.container.style.transition = '';
}
setupStyles() {
const style = document.createElement('style');
style.textContent = `
/* Theme Variables */
/*--------------------------------------------------
Import Fonts
--------------------------------------------------*/
@import url('https://fonts.googleapis.com/css2?family=Segment7Standard&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
/*--------------------------------------------------
Global Variables & Base Styles
--------------------------------------------------*/
:root {
/* Pball UI Variables */
--background-dark: #18181b;
--background-darker: #2e2e35;
--card-background: #1f1f26;
--pball-border-color: #3e3e45;
--pball-highlight-color: #76c7c0;
--pball-highlight-gradient: linear-gradient(90deg, var(--pball-highlight-color), #4db6ac);
--text-light: #ffffff;
--text-muted: #ccc;
--font-family: 'Roboto', sans-serif;
--glass-effect: rgba(255, 255, 255, 0.1);
--scrollbar-track: var(--background-darker);
--scrollbar-thumb: var(--pball-border-color);
--scrollbar-thumb-hover: #555;
/* Timer UI Variables */
--timer-color-led: #00ff37;
--timer-color-low: #ff4040;
--timer-font-led: 'Segment7Standard', monospace;
--timer-font-label: 'Press Start 2P', cursive;
--timer-label-text-color: #FFE135;
--timer-transition-speed: 0.3s;
--timer-font-size-label: 0.75rem; /* 12px */
--timer-font-size-countdown: 2rem; /* 32px */
}
/* Global & Scrollbar Styles */
.pball-container,
.pball-container * {
font-family: var(--font-family);
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 8px;
border: 1px solid var(--background-dark);
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/*--------------------------------------------------
Pball Container & Components
--------------------------------------------------*/
/* Main Container (Fixed Position) */
.pball-container {
position: fixed;
right: 12px;
bottom: 95px;
z-index: 10000;
user-select: none;
transform: scale(1);
transform-origin: top left;
width: fit-content;
height: fit-content;
pointer-events: none;
}
/* Allow interactions for designated children */
.pball-container > .pball-button,
.pball-panel.active {
pointer-events: auto;
}
/* Interactive Button */
.pball-button {
cursor: pointer;
width: 50px;
height: 50px;
border-radius: 50%;
border: 2px solid var(--pball-border-color);
background: var(--background-dark);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.pball-button:hover {
transform: scale(1.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
}
/* Floating Panel */
.pball-panel {
position: absolute;
bottom: calc(100% + 10px);
right: 0;
width: 320px;
background: var(--background-dark);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
border: 1px solid var(--glass-effect);
opacity: 0;
visibility: hidden;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s;
}
.pball-panel.active {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* Tab System */
.pball-tabs {
display: flex;
background: var(--background-darker);
border-bottom: 1px solid var(--pball-border-color);
}
.pball-tabs .pball-tab {
flex: 1;
padding: 10px;
text-align: center;
font-size: 16px;
cursor: pointer;
color: var(--text-muted);
transition: background 0.2s ease, color 0.2s ease;
}
.pball-tabs .pball-tab.active,
.pball-tabs .pball-tab:hover {
background: var(--pball-border-color);
color: var(--text-light);
}
/* Search Components */
.pball-search-container {
position: relative;
margin: 12px;
}
.pball-search-container .pball-search {
width: 100%;
padding: 8px 36px 8px 12px;
border: 1px solid var(--pball-border-color);
border-radius: 8px;
background: var(--background-dark);
color: var(--text-light);
font-size: 15px;
outline: none;
transition: border-color 0.2s ease;
}
.pball-search-container .pball-search:focus {
border-color: var(--pball-highlight-color);
}
.pball-search-container .pball-search::placeholder {
color: var(--text-muted);
}
.pball-search-container .pball-clear-btn {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
display: none;
}
/* Grid Layouts for Items */
.pball-grid {
padding: 12px;
display: grid;
gap: 12px;
max-height: 300px;
overflow-y: auto;
}
.pball-grid.ball-items {
grid-template-columns: repeat(3, 1fr);
}
.pball-grid.search-results {
grid-template-columns: 1fr;
}
/* Item Card */
.pball-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 50%;
padding: 8px;
cursor: default;
}
.pball-item img {
pointer-events: auto;
user-select: none;
cursor: grab;
width: 36px;
height: 36px;
transition: transform 0.2s ease;
}
.pball-item img:hover {
transform: scale(1.3);
}
.pball-item img.dragging {
opacity: 0.6;
transform: scale(0.8);
filter: drop-shadow(0 0 4px rgba(118, 199, 192, 0.5));
}
.pball-item .pball-label {
margin-top: 6px;
font-size: 13px;
color: var(--text-light);
text-align: center;
}
/* Browse Tab Components */
.browse-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
padding: 16px;
}
.browse-tile {
display: flex;
flex-direction: column;
align-items: center;
background: var(--background-darker);
border: 1px solid var(--pball-border-color);
border-radius: 12px;
padding: 12px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
text-align: center;
}
.browse-tile:hover {
transform: translateY(-3px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.browse-tile img {
width: 64px;
height: 64px;
margin-bottom: 6px;
}
.browse-tile .tile-label {
font-size: 14px;
font-weight: 500;
color: var(--text-light);
text-transform: capitalize;
}
/* Advanced Card Components */
.poke-card {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.poke-card .section-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-light);
}
.poke-card .stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
/* Evolution Chain */
.evolution-chain {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
padding: 8px;
}
.evolution-chain .evolution-item {
text-align: center;
padding: 6px;
background: var(--background-darker);
border-radius: 8px;
transition: transform 0.2s ease;
margin-bottom: 4px;
}
.evolution-chain .evolution-item img {
width: 64px;
height: 64px;
object-fit: contain;
margin-bottom: 4px;
}
.evolution-chain .evolution-item p {
font-size: 12px;
color: var(--text-light);
margin: 0;
}
/* Moves Section */
.moves-section {
max-height: 300px;
overflow-y: auto;
}
/*--------------------------------------------------
Timer Container & Components
--------------------------------------------------*/
/* Timer Container with Glassmorphism & Grid Layout */
.spawn-timer {
position: absolute;
bottom: calc(100% - 4.7rem);
right: 4.8rem;
padding: 0.75rem 1rem;
transform: perspective(500px) rotateX(5deg);
z-index: 10001;
display: flex; /* Changed from grid to flex */
align-items: center; /* Vertical alignment */
gap: 1rem; /* Space between elements */
transition: transform var(--timer-transition-speed) ease;
}
.spawn-timer:hover {
transform: perspective(500px) rotateX(0deg);
}
.spawn-timer::before {
content: '';
position: absolute;
top: -0.5rem;
left: -0.5rem;
right: -0.5rem;
bottom: -0.5rem;
pointer-events: none;
}
/* Timer Header: Centered Layout */
.timer-header {
display: flex;
grid-template-columns: 1fr;
justify-items: center;
align-items: center;
gap: 0.25rem;
width: 100%;
}
/* Timer Label with Elegant Typography */
.timer-label {
font-family: var(--timer-font-label);
font-size: var(--timer-font-size-label);
color: var(--timer-label-text-color);
text-transform: uppercase;
letter-spacing: 0.09375rem;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
border: 0.0625rem solid rgba(58, 90, 109, 0.4);
background: rgba(255, 255, 255, 0.05);
text-shadow: 0 0.0625rem 0.125rem rgba(0, 0, 0, 0.4),
0 0.5rem 0.5rem rgba(148, 180, 196, 0.3);
transition: background var(--timer-transition-speed) ease;
}
.timer-label:hover {
background: rgba(255, 255, 255, 0.15);
}
/* Countdown Display (Neon & Interactive) */
.countdown-display {
font-family: var(--timer-font-led);
font-size: var(--timer-font-size-countdown);
font-weight: bold;
color: var(--timer-color-led);
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
letter-spacing: 0.125rem;
min-width: 7.5rem;
text-align: center;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.05));
box-shadow: 0 0.375rem 0.75rem rgba(0, 255, 55, 0.4),
inset 0 0.25rem 0.5rem rgba(0, 255, 55, 0.3);
position: relative;
line-height: 1;
transition: transform var(--timer-transition-speed) ease, box-shadow var(--timer-transition-speed) ease;
}
.countdown-display:hover {
transform: scale(1.05);
box-shadow: 0 0.5rem 1rem rgba(0, 255, 55, 0.6),
inset 0 0.375rem 0.75rem rgba(0, 255, 55, 0.4);
}
.countdown-display::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle, rgba(0, 255, 55, 0.2) 0%, transparent 70%);
border-radius: inherit;
z-index: -1;
transition: opacity var(--timer-transition-speed) ease;
}
.countdown-display::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
z-index: -2;
}
/* Low Time Warning (Enhanced Neon Pulse) */
.countdown-display.low-time {
color: var(--timer-color-low);
text-shadow: 0 0.5rem 0.75rem rgba(255, 64, 64, 0.8),
0 0.25rem 0.375rem rgba(255, 64, 64, 0.9);
animation: led-pulse 0.6s ease-in-out infinite, emergency-glow 1.2s ease-in-out infinite;
}
@keyframes led-pulse {
0%, 100% { text-shadow: 0 0.5rem 0.75rem rgba(255, 64, 64, 0.8); }
50% { text-shadow: 0 0.75rem 1rem rgba(255, 64, 64, 1); }
}
@keyframes emergency-glow {
0%, 100% { box-shadow: 0 0.375rem 0.75rem rgba(255, 64, 64, 0.3); }
50% { box-shadow: 0 0.75rem 1rem rgba(255, 64, 64, 0.6); }
}
/* Utility: Spinner Animation */
.spinner {
margin: 1.5rem auto;
border: 0.25rem solid var(--pball-border-color);
border-top: 0.25rem solid var(--pball-highlight-color);
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/*--------------------------------------------------
Responsive Adjustments
--------------------------------------------------*/
@media (max-width: 768px) {
.poke-card {
padding: 16px;
gap: 16px;
}
.section-title {
font-size: 18px;
}
.stats-radar-chart canvas {
max-width: 100%;
}
}
/* Responsive adjustments */
@media (max-width: 600px) {
.spawn-timer {
gap: 0.75rem; /* Reduced gap for mobile */
padding: 0.5rem 0.75rem;
}
.timer-label {
font-size: 0.6rem; /* Slightly smaller text */
padding: 0.2rem 0.4rem;
}
.countdown-display {
font-size: 1.5rem; /* Slightly smaller countdown */
min-width: 4.5rem;
}
}
`;
document.head.appendChild(style);
}
async waitForChat() {
return new Promise((resolve) => {
if (document.querySelector('[data-test-selector="chat-input"]')) {
return resolve();
}
const observer = new MutationObserver(() => {
if (document.querySelector('[data-test-selector="chat-input"]')) {
observer.disconnect();
resolve();
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
createInterface() {
this.container = document.createElement('div');
this.container.className = 'pball-container';
this.button = this.createMainButton();
this.panel = this.createPanel();
this.container.append(this.button, this.panel);
document.body.appendChild(this.container);
}
createMainButton() {
const button = document.createElement('img');
button.draggable = false;
button.className = 'pball-button';
button.src = this.catchBalls.poke.image;
return button;
}
createPanel() {
const panel = document.createElement('div');
panel.className = 'pball-panel';
panel.draggable = false;
const tabsContainer = document.createElement('div');
tabsContainer.className = 'pball-tabs';
const catchTab = document.createElement('div');
catchTab.className = 'pball-tab active';
catchTab.textContent = 'Catch';
catchTab.dataset.tab = 'catch';
tabsContainer.appendChild(catchTab);
const shopTab = document.createElement('div');
shopTab.className = 'pball-tab';
shopTab.textContent = 'Shop';
shopTab.dataset.tab = 'shop';
tabsContainer.appendChild(shopTab);
const browseTab = document.createElement('div');
browseTab.className = 'pball-tab';
browseTab.textContent = 'Browse';
browseTab.dataset.tab = 'browse';
tabsContainer.appendChild(browseTab);
const advancedTab = document.createElement('div');
advancedTab.className = 'pball-tab';
advancedTab.textContent = 'Advanced';
advancedTab.dataset.tab = 'advanced';
tabsContainer.appendChild(advancedTab);
const searchContainer = document.createElement('div');
searchContainer.className = 'pball-search-container';
this.searchInput = document.createElement('input');
this.searchInput.type = 'text';
this.searchInput.className = 'pball-search';
this.searchInput.placeholder = 'Search...';
this.searchInput.setAttribute('aria-label', 'Search Pokémon');
this.clearBtn = document.createElement('button');
this.clearBtn.className = 'pball-clear-btn';
this.clearBtn.textContent = '×';
this.clearBtn.setAttribute('aria-label', 'Clear Search');
searchContainer.append(this.searchInput, this.clearBtn);
this.gridContainer = document.createElement('div');
this.gridContainer.className = 'pball-grid';
panel.append(tabsContainer, searchContainer, this.gridContainer);
return panel;
}
createTimerElement() {
// Create and insert timer element
this.timerContainer = document.createElement('div');
this.timerContainer.className = 'spawn-timer';
this.timerContainer.innerHTML = `
<div class="timer-header">
<div class="countdown-display">88:88</div>
</div>
`;
this.container.appendChild(this.timerContainer);
this.initTimer();
}
initTimer() {
// Save references and default values
this.backendUrl = 'https://poketwitch.bframework.de/info/events/last_spawn/';
this.countdownElement = this.timerContainer.querySelector('.countdown-display');
this.remainingSeconds = 0;
this.tickInterval = null;
this.apiInterval = null;
this.isFetching = false;
// Start the per-second countdown
this.startTick();
// Get the initial time from the API
this.fetchTimer();
// Update the timer from the API every minute
this.apiInterval = setInterval(() => {
this.fetchTimer();
}, 60000);
// Emergency refresh on click (ignoring if a fetch is already in progress)
this.timerContainer.addEventListener('click', () => {
if (!this.isFetching) {
this.fetchTimer();
}
});
}
startTick() {
// Clear any existing tick interval
if (this.tickInterval) clearInterval(this.tickInterval);
// Update the display every second using the local remainingSeconds counter
this.tickInterval = setInterval(() => {
if (this.remainingSeconds > 0) {
this.remainingSeconds--;
this.updateDisplay(this.remainingSeconds);
} else {
// If timer has run out, you might decide to call fetchTimer() here to refresh
this.updateDisplay(0);
}
}, 1000);
}
updateDisplay(seconds) {
if (isNaN(seconds) || seconds < 0) {
this.countdownElement.textContent = '--:--';
this.countdownElement.classList.remove('low-time');
return;
}
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
this.countdownElement.textContent =
`${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
// Toggle warning state when time is low (<= 30 seconds)
this.countdownElement.classList.toggle('low-time', seconds <= 30);
}
fetchTimer() {
// Prevent overlapping API calls
if (this.isFetching) return;
this.isFetching = true;
fetch(this.backendUrl)
.then(response => {
if (!response.ok) throw new Error('Network error');
return response.json();
})
.then(data => {
const newTime = parseInt(data.next_spawn, 10);
if (isNaN(newTime)) throw new Error('Invalid timer data');
// Reset the remaining seconds with the new value from the backend.
this.remainingSeconds = newTime;
this.updateDisplay(this.remainingSeconds);
})
.catch(error => {
console.error('Timer error:', error);
this.remainingSeconds = -1;
this.updateDisplay(NaN);
})
.finally(() => {
this.isFetching = false;
});
}
renderGrid() {
if (this.currentTab === 'advanced') {
this.gridContainer.classList.remove('ball-items');
this.gridContainer.classList.add('search-results');
this.renderAdvancedInstruction();
} else if (this.currentTab === 'browse') {
this.gridContainer.classList.remove('ball-items');
this.gridContainer.classList.add('search-results');
this.renderBrowse();
} else {
this.gridContainer.classList.remove('search-results');
this.gridContainer.classList.add('ball-items');
this.gridContainer.innerHTML = '';
const balls = this.currentTab === 'catch' ? this.catchBalls : this.shopBalls;
Object.entries(balls).forEach(([key, ball]) => {
const item = document.createElement('div');
item.className = 'pball-item';
item.dataset.label = ball.tooltip.toLowerCase();
const img = document.createElement('img');
img.src = ball.image;
img.dataset.ballType = ball.command;
img.draggable = true;
const label = document.createElement('div');
label.className = 'pball-label';
label.textContent = ball.tooltip;
item.append(img, label);
this.gridContainer.appendChild(item);
});
this.filterGrid();
}
}
renderAdvancedInstruction() {
this.gridContainer.innerHTML = '';
const info = document.createElement('div');
info.style.padding = '12px';
info.style.textAlign = 'center';
info.style.color = 'var(--text-light)';
info.textContent = 'Enter a Pokémon name and press Enter for detailed info.';
this.gridContainer.appendChild(info);
}
renderBrowse() {
this.gridContainer.innerHTML = '';
if (!this.pokemonList) {
this.gridContainer.innerHTML = '<div class="spinner"></div>';
fetch('https://pokeapi.co/api/v2/pokemon?limit=20000')
.then(response => response.json())
.then(data => {
this.pokemonList = data.results;
this.renderBrowseGrid();
})
.catch(err => {
this.gridContainer.innerHTML = `<div style="padding:12px; color: var(--text-light);">Error loading Pokémon list</div>`;
});
} else {
this.renderBrowseGrid();
}
}
renderBrowseGrid() {
this.gridContainer.innerHTML = '';
this.gridContainer.classList.add('browse-container');
const query = this.searchInput.value.trim().toLowerCase();
const filtered = this.pokemonList.filter(poke => poke.name.includes(query));
filtered.forEach(poke => {
const tile = document.createElement('div');
tile.className = 'browse-tile';
tile.dataset.label = poke.name.toLowerCase();
const idMatch = poke.url.match(/\/pokemon\/(\d+)\//);
const id = idMatch ? idMatch[1] : '';
const img = document.createElement('img');
img.src = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`;
const label = document.createElement('div');
label.className = 'tile-label';
label.textContent = poke.name;
tile.append(img, label);
tile.addEventListener('click', (e) => {
e.stopPropagation();
this.panel.classList.add('active');
this.changeTab('advanced');
this.searchInput.value = poke.name;
this.searchAdvancedPokemon(poke.name);
});
this.gridContainer.appendChild(tile);
});
if (filtered.length === 0) {
this.gridContainer.innerHTML = `<div style="padding:12px; color: var(--text-light);">No Pokémon match your search.</div>`;
}
}
addEventListeners() {
this.button.addEventListener('mousedown', this.dragStart);
this.button.addEventListener('click', (e) => {
if (this.wasDragging) {
this.wasDragging = false;
return;
}
e.stopPropagation();
this.panel.classList.toggle('active');
if (this.panel.classList.contains('active')) {
this.searchInput.focus();
}
});
document.addEventListener('click', (e) => {
if (!this.container.contains(e.target)) {
this.panel.classList.remove('active');
}
});
this.panel.addEventListener('dragstart', (e) => {
const ballImg = e.target.closest('.pball-item img');
if (ballImg) {
e.dataTransfer.setData('text/plain', ballImg.dataset.ballType);
const dragImg = new Image();
dragImg.src = ballImg.src;
dragImg.style.width = '36px';
dragImg.style.height = '36px';
dragImg.style.position = 'absolute';
dragImg.style.left = '-9999px';
document.body.appendChild(dragImg);
e.dataTransfer.setDragImage(dragImg, 18, 18);
setTimeout(() => document.body.removeChild(dragImg), 0);
ballImg.classList.add('dragging');
const onDragEnd = () => {
ballImg.classList.remove('dragging');
document.removeEventListener('dragend', onDragEnd);
};
document.addEventListener('dragend', onDragEnd);
}
});
const chatInput = document.querySelector('#chatInput');
if (chatInput) {
chatInput.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
chatInput.addEventListener('drop', (e) => {
e.preventDefault();
const ballType = e.dataTransfer.getData('text/plain');
if (ballType) {
chatInput.value += ` ${ballType}`;
}
});
}
const tabs = this.panel.querySelectorAll('.pball-tab');
tabs.forEach(tab => {
tab.addEventListener('click', (e) => {
e.stopPropagation();
this.changeTab(tab.dataset.tab);
});
});
this.searchInput.addEventListener('input', () => {
if (this.currentTab !== 'advanced') {
this.filterGrid();
if (this.currentTab === 'browse') {
this.renderBrowseGrid();
}
}
this.clearBtn.style.display = this.searchInput.value.trim() ? 'block' : 'none';
});
this.clearBtn.addEventListener('click', () => {
this.searchInput.value = '';
this.clearBtn.style.display = 'none';
if (this.currentTab !== 'advanced') {
this.filterGrid();
if (this.currentTab === 'browse') {
this.renderBrowseGrid();
}
}
});
this.searchInput.addEventListener('keydown', (e) => {
if (this.currentTab === 'advanced' && e.key === 'Enter') {
this.searchAdvancedPokemon(this.searchInput.value.trim());
}
});
}
changeTab(tabName) {
this.currentTab = tabName;
const tabs = this.panel.querySelectorAll('.pball-tab');
tabs.forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
if (tabName === 'advanced') {
this.searchInput.placeholder = 'Enter Pokémon name for detailed info...';
} else if (tabName === 'browse') {
this.searchInput.placeholder = 'Filter Pokémon...';
} else {
this.searchInput.placeholder = 'Search...';
}
this.searchInput.value = '';
this.clearBtn.style.display = 'none';
this.renderGrid();
}
filterGrid() {
const query = this.searchInput.value.trim().toLowerCase();
const items = this.gridContainer.querySelectorAll('.pball-item, .browse-tile');
items.forEach(item => {
if (!query || item.dataset.label.includes(query)) {
item.style.display = 'flex';
} else {
item.style.display = 'none';
}
});
}
getChatInput() {
return document.querySelector('[data-a-target="chat-input"]');
}
insertCommand(ballType) {
const chatInput = this.getChatInput();
if (!chatInput) return;
chatInput.focus();
this.clearChatInput();
this.insertText(ballType);
this.triggerInputEvent(chatInput);
}
clearChatInput() {
const chatInput = this.getChatInput();
if (chatInput) {
chatInput.value = '';
this.triggerInputEvent(chatInput);
}
}
insertText(text) {
document.execCommand('insertText', false, text);
}
triggerInputEvent(element) {
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
}
searchAdvancedPokemon(name) {
if (!name) return;
this.gridContainer.innerHTML = '<div class="spinner"></div>';
fetch(`https://pokeapi.co/api/v2/pokemon/${name.toLowerCase()}`)
.then(response => {
if (!response.ok) { throw new Error("Pokémon not found"); }
return response.json();
})
.then(data => {
return fetch(data.species.url)
.then(res => {
if (!res.ok) { throw new Error("Species data not found"); }
return res.json().then(speciesData => ({ data, speciesData }));
});
})
.then(({ data, speciesData }) => {
return fetch(speciesData.evolution_chain.url)
.then(res => {
if (!res.ok) { throw new Error("Evolution chain not found"); }
return res.json().then(evoData => ({ data, speciesData, evoData }));
});
})
.then(({ data, speciesData, evoData }) => {
this.displayAdvancedPokemonData(data, speciesData, evoData);
})
.catch(err => {
this.gridContainer.innerHTML = `<div style="padding:12px; color: var(--text-light);">${err.message}</div>`;
});
}
displayAdvancedPokemonData(data, speciesData, evoData) {
this.gridContainer.innerHTML = '';
const card = document.createElement('div');
card.className = 'poke-card';
// Constrain the card width to fit within the pop-up
card.style.maxWidth = '100%'; // Ensure it doesn't exceed the pop-up width
card.style.overflowX = 'hidden'; // Prevent horizontal scrolling
// Header Section
const header = this.createCardHeader(data);
card.appendChild(header);
// Single Column Layout for Advanced Tab
card.style.display = 'flex';
card.style.flexDirection = 'column';
card.style.gap = '16px'; // Reduced gap for compact layout
// Add sections in a single column
card.append(
this.createBasicInfoSection(data),
this.createStatsRadarChart(data),
this.createAbilitiesSection(data),
this.createTypeRelationsGrid(data),
this.createPokedexEntrySection(speciesData),
this.createEvolutionVisualization(evoData.chain),
this.createMovesSection(data)
);
// Add Held Items and Forms sections if they exist
if (data.held_items && data.held_items.length > 0) {
card.appendChild(this.createHeldItemsSection(data));
}
if (data.forms && data.forms.length > 0) {
card.appendChild(this.createFormsSection(data));
}
this.gridContainer.appendChild(card);
}
createCardHeader(data) {
const header = document.createElement('header');
header.className = 'poke-card-header';
header.style.display = 'grid';
header.style.gridTemplateColumns = 'auto 1fr';
header.style.gap = '24px';
header.style.alignItems = 'center';
header.style.marginBottom = '24px';
// Image with Badge
const imgContainer = document.createElement('div');
imgContainer.style.position = 'relative';
const img = document.createElement('img');
img.className = 'poke-image';
img.style.width = '90px';
img.style.height = '90px';
img.style.borderRadius = '16px';
img.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)';
img.src = data.sprites.other?.['official-artwork']?.front_default || data.sprites.front_default;
// Type Badges
const typeBadges = document.createElement('div');
typeBadges.style.display = 'flex';
typeBadges.style.gap = '4px';
typeBadges.style.position = 'absolute';
typeBadges.style.bottom = '-23px';
typeBadges.style.left = '55%';
typeBadges.style.transform = 'translateX(-50%)';
data.types.forEach(type => {
const badge = document.createElement('span');
badge.className = 'type-badge';
badge.textContent = type.type.name.toUpperCase();
badge.style.background = this.getTypeColor(type.type.name);
badge.style.padding = '3px 3px';
badge.style.borderRadius = '5px';
badge.style.fontSize = '12px';
badge.style.fontWeight = '700';
badge.style.color = '#fff';
badge.style.textShadow = '0 1px 2px rgba(0,0,0,0.3)';
typeBadges.appendChild(badge);
});
imgContainer.append(img, typeBadges);
// Title Section
const titleSection = document.createElement('div');
const title = document.createElement('h1');
title.className = 'poke-title';
title.style.fontSize = '32px';
title.style.margin = '0 0 8px';
title.textContent = data.name.charAt(0).toUpperCase() + data.name.slice(1);
const details = document.createElement('div');
details.style.display = 'grid';
details.style.gridTemplateColumns = 'repeat(3, auto)';
details.style.gap = '16px';
details.innerHTML = `
<div class="detail-item">
<span class="detail-label">ID</span>
<span class="detail-value">#${data.id.toString().padStart(3, '0')}</span>
</div>
<div class="detail-item">
<span class="detail-label">EXP</span>
<span class="detail-value">${data.base_experience}</span>
</div>
<div class="detail-item">
<span class="detail-label">SPECIES</span>
<span class="detail-value">${data.species.name}</span>
</div>
`;
titleSection.append(title, details);
header.append(imgContainer, titleSection);
return header;
}
createPokedexEntrySection(speciesData) {
const section = document.createElement('div');
section.className = 'pokedex-entry-section';
section.innerHTML = `<h3 class="section-title">POKÉDEX ENTRY</h3>`;
// Find the English flavor text
const entry = speciesData.flavor_text_entries.find(e => e.language.name === 'en');
const flavorText = entry ? entry.flavor_text.replace(/\f|\n/g, ' ') : 'No entry available.';
// Create the entry container
const entryContainer = document.createElement('div');
entryContainer.className = 'pokedex-entry';
entryContainer.style.background = 'var(--background-darker)';
entryContainer.style.padding = '16px';
entryContainer.style.borderRadius = '8px';
entryContainer.style.fontSize = '14px';
entryContainer.style.lineHeight = '1.5';
entryContainer.style.color = 'var(--text-muted)';
entryContainer.textContent = flavorText;
section.appendChild(entryContainer);
return section;
}
createBasicInfoSection(data) {
const section = document.createElement('div');
section.className = 'info-grid';
section.innerHTML = `
<h3 class="section-title">PHYSICAL TRAITS</h3>
<div class="metric">
<i class="icon-height"></i>
<span class="label">Height</span>
<span class="value">${data.height / 10}m</span>
</div>
<div class="metric">
<i class="icon-weight"></i>
<span class="label">Weight</span>
<span class="value">${data.weight / 10}kg</span>
</div>
<div class="metric">
<i class="icon-stats"></i>
<span class="label">Total Stats</span>
<span class="value">${data.stats.reduce((sum, s) => sum + s.base_stat, 0)}</span>
</div>
`;
return section;
}
createAbilitiesSection(data) {
const section = document.createElement('div');
section.className = 'abilities-section';
section.innerHTML = `<h3 class="section-title">ABILITIES</h3>`;
const abilitiesGrid = document.createElement('div');
abilitiesGrid.className = 'abilities-grid';
abilitiesGrid.style.display = 'grid';
abilitiesGrid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(140px, 1fr))';
abilitiesGrid.style.gap = '12px';
data.abilities.forEach(ability => {
const abilityCard = document.createElement('div');
abilityCard.className = 'ability-card';
abilityCard.style.background = 'var(--background-darker)';
abilityCard.style.padding = '12px';
abilityCard.style.borderRadius = '8px';
abilityCard.style.textAlign = 'center';
abilityCard.style.position = 'relative';
const abilityName = document.createElement('div');
abilityName.textContent = ability.ability.name.replace(/-/g, ' ');
abilityName.style.fontWeight = '500';
abilityName.style.textTransform = 'capitalize';
if (ability.is_hidden) {
const hiddenBadge = document.createElement('div');
hiddenBadge.textContent = 'Hidden';
hiddenBadge.style.position = 'absolute';
hiddenBadge.style.top = '4px';
hiddenBadge.style.right = '4px';
hiddenBadge.style.background = '#FF6B6B';
hiddenBadge.style.color = '#FFF';
hiddenBadge.style.fontSize = '10px';
hiddenBadge.style.padding = '2px 6px';
hiddenBadge.style.borderRadius = '12px';
abilityCard.appendChild(hiddenBadge);
}
abilityCard.appendChild(abilityName);
abilitiesGrid.appendChild(abilityCard);
// Add tooltip for ability description
abilityCard.addEventListener('mouseenter', () => {
fetch(ability.ability.url)
.then(res => res.json())
.then(abilityData => {
const description = abilityData.effect_entries.find(e => e.language.name === 'en')?.effect || 'No description available.';
this.showTooltip(abilityCard, description);
});
});
abilityCard.addEventListener('mouseleave', () => {
this.hideTooltip();
});
});
section.appendChild(abilitiesGrid);
return section;
}
showTooltip(element, text) {
if (this.tooltip) this.tooltip.remove();
this.tooltip = document.createElement('div');
this.tooltip.className = 'tooltip';
this.tooltip.textContent = text;
this.tooltip.style.position = 'absolute';
this.tooltip.style.background = 'var(--background-dark)';
this.tooltip.style.color = 'var(--text-light)';
this.tooltip.style.padding = '8px 12px';
this.tooltip.style.borderRadius = '6px';
this.tooltip.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)';
this.tooltip.style.zIndex = '10000';
this.tooltip.style.maxWidth = '240px';
this.tooltip.style.fontSize = '14px';
const rect = element.getBoundingClientRect();
this.tooltip.style.top = `${rect.bottom + window.scrollY + 8}px`;
this.tooltip.style.left = `${rect.left + window.scrollX}px`;
document.body.appendChild(this.tooltip);
}
hideTooltip() {
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
}
createMovesSection(data) {
const section = document.createElement('div');
section.className = 'moves-section';
section.innerHTML = `<h3 class="section-title">MOVES</h3>`;
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search moves...';
searchInput.style.width = '100%';
searchInput.style.padding = '8px 12px';
searchInput.style.marginBottom = '12px';
searchInput.style.borderRadius = '6px';
searchInput.style.background = 'var(--background-darker)';
searchInput.style.color = 'var(--text-light)';
searchInput.style.border = '1px solid var(--border-color)';
const movesList = document.createElement('div');
movesList.className = 'moves-list';
movesList.style.maxHeight = '200px';
movesList.style.overflowY = 'auto';
movesList.style.display = 'grid';
movesList.style.gap = '8px';
data.moves.forEach(move => {
const moveItem = document.createElement('div');
moveItem.className = 'move-item';
moveItem.textContent = move.move.name.replace(/-/g, ' ');
moveItem.style.padding = '8px 12px';
moveItem.style.background = 'var(--background-darker)';
moveItem.style.borderRadius = '6px';
moveItem.style.textTransform = 'capitalize';
movesList.appendChild(moveItem);
});
searchInput.addEventListener('input', () => {
const query = searchInput.value.trim().toLowerCase();
Array.from(movesList.children).forEach(move => {
move.style.display = move.textContent.toLowerCase().includes(query) ? 'block' : 'none';
});
});
section.append(searchInput, movesList);
return section;
}
createHeldItemsSection(data) {
const section = document.createElement('div');
section.className = 'held-items-section';
section.innerHTML = `<h3 class="section-title">HELD ITEMS</h3>`;
const itemsGrid = document.createElement('div');
itemsGrid.className = 'items-grid';
itemsGrid.style.display = 'grid';
itemsGrid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(120px, 1fr))';
itemsGrid.style.gap = '12px';
data.held_items.forEach(item => {
const itemCard = document.createElement('div');
itemCard.className = 'item-card';
itemCard.textContent = item.item.name.replace(/-/g, ' ');
itemCard.style.padding = '12px';
itemCard.style.background = 'var(--background-darker)';
itemCard.style.borderRadius = '8px';
itemCard.style.textAlign = 'center';
itemCard.style.textTransform = 'capitalize';
itemsGrid.appendChild(itemCard);
});
section.appendChild(itemsGrid);
return section;
}
createFormsSection(data) {
const section = document.createElement('div');
section.className = 'forms-section';
section.innerHTML = `<h3 class="section-title">FORMS</h3>`;
const formsGrid = document.createElement('div');
formsGrid.className = 'forms-grid';
formsGrid.style.display = 'grid';
formsGrid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(120px, 1fr))';
formsGrid.style.gap = '12px';
data.forms.forEach(form => {
const formCard = document.createElement('div');
formCard.className = 'form-card';
formCard.textContent = form.name.replace(/-/g, ' ');
formCard.style.padding = '12px';
formCard.style.background = 'var(--background-darker)';
formCard.style.borderRadius = '8px';
formCard.style.textAlign = 'center';
formCard.style.textTransform = 'capitalize';
formsGrid.appendChild(formCard);
});
section.appendChild(formsGrid);
return section;
}
createStatsRadarChart(data) {
const section = document.createElement('div');
section.className = 'stats-radar-card';
section.innerHTML = `
<div class="stats-header">
<h3 class="section-title">Stat Distribution</h3>
<div class="stats-summary">
<span class="total-stats">Total: ${data.stats.reduce((sum, s) => sum + s.base_stat, 0)}</span>
<div class="type-badge" style="background: ${this.getTypeColor(data.types[0].type.name)}">
${data.types[0].type.name.toUpperCase()}
</div>
</div>
</div>
`;
// Chart container with aspect ratio constraints
const chartContainer = document.createElement('div');
chartContainer.className = 'radar-container';
chartContainer.style.position = 'relative';
chartContainer.style.height = 'clamp(280px, 35vh, 400px)';
chartContainer.style.margin = '16px 0';
const canvas = document.createElement('canvas');
canvas.setAttribute('aria-label', 'Pokémon stat radar chart');
canvas.style.touchAction = 'none';
// Dynamic gradient based on Pokémon type
const typeColor = this.getTypeColor(data.types[0].type.name);
const gradient = {
light: this.hexToRgba(typeColor, 0.3),
dark: this.hexToRgba(typeColor, 0.1)
};
// Chart.js loader with error handling
if (!window.Chart) {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/chart.js';
script.onload = () => this.drawEnhancedRadar(canvas, data, gradient);
script.onerror = () => this.showChartError(chartContainer);
document.head.appendChild(script);
} else {
this.drawEnhancedRadar(canvas, data, gradient);
}
chartContainer.appendChild(canvas);
section.appendChild(chartContainer);
return section;
}
drawEnhancedRadar(canvas, data, gradient) {
try {
const ctx = canvas.getContext('2d');
const stats = data.stats.map(s => s.base_stat);
const labels = data.stats.map(s => ({
full: s.stat.name.replace(/-/g, ' '),
short: this.getStatAbbreviation(s.stat.name)
}));
// Create gradient fill
const chartGradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
chartGradient.addColorStop(0, gradient.light);
chartGradient.addColorStop(1, gradient.dark);
new Chart(ctx, {
type: 'radar',
data: {
labels: labels.map(l => l.short),
datasets: [{
data: stats,
backgroundColor: chartGradient,
borderColor: this.hexToRgba(gradient.light, 0.8),
borderWidth: 1.8,
pointBackgroundColor: '#ffffff',
pointBorderColor: gradient.light,
pointHoverRadius: 8,
pointRadius: 4,
pointHitRadius: 12,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 800,
easing: 'easeOutQuint'
},
scales: {
r: {
beginAtZero: true,
max: Math.ceil(Math.max(...stats) / 10) * 10 + 10,
ticks: {
display: false,
count: 5,
z: 1
},
grid: {
color: 'rgba(255, 255, 255, 0.12)',
circular: true,
lineWidth: 0.8
},
pointLabels: {
color: '#ffffff',
font: {
size: 13,
weight: '500'
},
callback: (value, index) => [`${value}`, stats[index]],
padding: 18
},
angleLines: {
color: 'rgba(255, 255, 255, 0.08)',
lineWidth: 0.8
}
}
},
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
intersect: false,
callbacks: {
title: (items) => labels[items[0].dataIndex].full,
label: (context) => `Base Stat: ${context.raw}`
},
bodyFont: { size: 13 },
titleFont: { size: 12 },
padding: 14,
backgroundColor: 'rgba(28, 28, 34, 0.96)',
borderColor: 'rgba(255, 255, 255, 0.12)',
borderWidth: 1,
cornerRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.24)'
},
annotation: {
annotations: {
avgLine: {
type: 'line',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
borderDash: [4, 4],
scaleID: 'r',
value: stats.reduce((a, b) => a + b, 0) / stats.length
}
}
}
},
onHover: (event, elements) => {
canvas.style.cursor = elements.length ? 'pointer' : 'default';
}
}
});
} catch (error) {
this.showChartError(canvas.parentElement);
}
}
// Helper methods
hexToRgba(hex, alpha = 1) {
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
return `rgba(${r},${g},${b},${alpha})`;
}
getStatAbbreviation(statName) {
const abbreviations = {
'hp': 'HP', 'attack': 'ATK', 'defense': 'DEF',
'special-attack': 'SP.ATK', 'special-defense': 'SP.DEF',
'speed': 'SPD'
};
return abbreviations[statName] || statName.slice(0, 3).toUpperCase();
}
showChartError(container) {
container.innerHTML = `
<div class="chart-error">
<svg class="error-icon" viewBox="0 0 24 24" width="48" height="48">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
<div class="error-message">
<h4>Chart Unavailable</h4>
<p>Failed to load stat visualization</p>
</div>
</div>
`;
}
createTypeRelationsGrid(data) {
const section = document.createElement('div');
section.className = 'type-relations-grid';
section.innerHTML = `<h3 class="section-title">TYPE INTERACTIONS</h3>`;
const grid = document.createElement('div');
grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(160px, 1fr))';
grid.style.gap = '12px';
data.types.forEach(type => {
const typeCard = document.createElement('div');
typeCard.className = 'type-card';
typeCard.innerHTML = `
<div class="type-header">${type.type.name.toUpperCase()}</div>
<div class="damage-relations">
<div class="strengths">
<h4>STRONG VS</h4>
<div class="types-list"></div>
</div>
<div class="weaknesses">
<h4>WEAK TO</h4>
<div class="types-list"></div>
</div>
</div>
`;
// Add this block to target the header specifically
const typeHeader = typeCard.querySelector('.type-header');
typeHeader.style.background = this.getTypeColor(type.type.name);
// Async load damage relations
fetch(type.type.url)
.then(res => res.json())
.then(typeData => {
const strengths = typeData.damage_relations.double_damage_to;
const weaknesses = typeData.damage_relations.double_damage_from;
strengths.forEach(t => {
const badge = this.createTypeBadge(t.name);
typeCard.querySelector('.strengths .types-list').appendChild(badge);
});
weaknesses.forEach(t => {
const badge = this.createTypeBadge(t.name);
typeCard.querySelector('.weaknesses .types-list').appendChild(badge);
});
});
grid.appendChild(typeCard);
});
section.appendChild(grid);
return section;
}
createTypeBadge(typeName) {
const badge = document.createElement('span');
badge.className = 'type-badge small';
badge.textContent = typeName.toUpperCase();
badge.style.background = this.getTypeColor(typeName);
badge.style.padding = '2px 8px';
badge.style.borderRadius = '12px';
badge.style.fontSize = '10px';
return badge;
}
getTypeColor(typeName) {
const typeColors = {
normal: '#A8A878',
fire: '#F08030',
water: '#6890F0',
electric: '#F8D030',
grass: '#78C850',
ice: '#98D8D8',
fighting: '#C03028',
poison: '#A040A0',
ground: '#E0C068',
flying: '#A890F0',
psychic: '#F85888',
bug: '#A8B820',
rock: '#B8A038',
ghost: '#705898',
dragon: '#7038F8',
dark: '#705848',
steel: '#B8B8D0',
fairy: '#EE99AC'
};
return typeColors[typeName] || '#68A090';
}
createEvolutionVisualization(chain) {
const section = document.createElement('div');
section.className = 'evolution-chain';
section.innerHTML = `<h3 class="section-title">EVOLUTION LINE</h3>`;
const stages = this.parseEvolutionChain(chain);
const container = document.createElement('div');
container.style.display = 'flex';
container.style.justifyContent = 'center';
container.style.gap = '0px';
container.style.padding = '16px 0';
stages.forEach((stage, index) => {
const stageDiv = document.createElement('div');
stageDiv.style.display = 'flex';
stageDiv.style.flexDirection = 'column';
stageDiv.style.alignItems = 'center';
stageDiv.style.gap = '8px';
if (index > 0) {
const arrow = document.createElement('div');
arrow.textContent = '→';
arrow.style.fontSize = '24px';
arrow.style.opacity = '0.6';
container.appendChild(arrow);
}
const sprite = document.createElement('img');
sprite.src = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${stage.id}.png`;
sprite.style.width = '64px';
sprite.style.height = '64px';
const name = document.createElement('div');
name.textContent = stage.name;
name.style.fontWeight = '500';
stageDiv.append(sprite, name);
container.appendChild(stageDiv);
});
section.appendChild(container);
return section;
}
parseEvolutionChain(chain, result = []) {
const id = chain.species.url.split('/').slice(-2, -1)[0];
result.push({ name: chain.species.name, id });
if (chain.evolves_to.length > 0) {
chain.evolves_to.forEach(e => this.parseEvolutionChain(e, result));
}
return result.filter((v, i, a) => a.findIndex(t => t.id === v.id) === i);
}
}
new PokeballHelper();
})();