// for some reason it isn't showing that you are dragging a fruit when dragging // from one cell to another. it actually is dragging but it doesn't show it // this is a helper to create a drag image so it shows the fruit being dragged function createDragImage(el) { const dragImg = el.cloneNode(true); dragImg.style.position = 'absolute'; dragImg.style.top = '-1000px'; dragImg.style.left = '-1000px'; document.body.appendChild(dragImg); return dragImg; } // helper to place a fruit div inside a square div function placeFruitInSquare(squareEl, fruitName) { // clear existing content except the square number const squareNumber = squareEl.dataset.squareId; squareEl.innerHTML = ''; // record the fruit in the square for tracking squareEl.dataset.fruit = fruitName; // find the template fruit in the palette const template = document.querySelector(`.fruit[data-fruit="${fruitName}"]`); if (!template) return; // clone and drop const clone = template.cloneNode(true); clone.classList.remove('fruit'); // remove extra styling if you like clone.classList.add('dropped-fruit'); clone.setAttribute('draggable', true); // allow repositioning if desired // add drag start for dragging from a square clone.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', fruitName); const dragImg = createDragImage(clone); e.dataTransfer.setDragImage(dragImg, dragImg.offsetWidth / 2, dragImg.offsetHeight / 2); clone.addEventListener('dragend', () => { dragImg.remove(); }, { once: true }); }); squareEl.appendChild(clone); // add X clear button const clearBtn = document.createElement('button'); clearBtn.classList.add('clear-btn'); clearBtn.textContent = 'X'; clearBtn.addEventListener('click', async (e) => { e.stopPropagation(); // prevent event bubbling squareEl.innerHTML = ''; delete squareEl.dataset.fruit; // re-add the square number after clearing const numDiv = document.createElement('div'); numDiv.className = 'square-number'; numDiv.textContent = squareNumber; squareEl.appendChild(numDiv); const squareId = squareEl.dataset.squareId; await fetch('/api/positions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ squareId, fruit: '' }), }); }); squareEl.appendChild(clearBtn); // add the square number in the bottom right const numDiv = document.createElement('div'); numDiv.className = 'square-number'; numDiv.textContent = squareNumber; squareEl.appendChild(numDiv); } // Helper to show an input box for custom fruit name function showCustomFruitInput(squareEl) { // Prevent multiple inputs if (squareEl.querySelector('.custom-fruit-input')) return; const squareNumber = squareEl.dataset.squareId; squareEl.innerHTML = ''; const input = document.createElement('input'); input.type = 'text'; input.placeholder = 'Enter name...'; input.className = 'custom-fruit-input'; input.style.width = '90%'; input.style.fontSize = '1em'; input.style.textAlign = 'center'; input.style.marginTop = '30px'; squareEl.appendChild(input); // Add the square number in the bottom right const numDiv = document.createElement('div'); numDiv.className = 'square-number'; numDiv.textContent = squareNumber; squareEl.appendChild(numDiv); input.focus(); // Handler for when user presses Enter input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { input.blur(); } }); input.addEventListener('blur', function () { const customName = input.value.trim(); if (!customName) { // Restore square if nothing entered squareEl.innerHTML = ''; squareEl.appendChild(numDiv); return; } // Wait for category selection waitForCategorySelection(customName, squareEl, squareNumber); }); } // Wait for user to click a category, then send to server function waitForCategorySelection(customName, squareEl, squareNumber) { // Highlight categories and show a message document.querySelectorAll('.categories').forEach(cat => { cat.classList.add('category-selectable'); }); // --- Add overlay to dim only the map area with transition --- let mapWrapper = document.getElementById('mapWrapper'); let overlay = document.createElement('div'); overlay.id = 'category-select-overlay'; overlay.style.position = 'absolute'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.background = 'rgba(0,0,0,0.55)'; overlay.style.zIndex = '999'; overlay.style.pointerEvents = 'auto'; overlay.style.opacity = '0'; overlay.style.transition = 'opacity 0.3s ease'; mapWrapper.appendChild(overlay); // Trigger fade-in requestAnimationFrame(() => { overlay.style.opacity = '1'; }); // --- end overlay --- const msg = document.createElement('div'); msg.innerHTML = 'Select a team to add ' + customName + ' to'; msg.className = 'category-select-msg'; msg.style.position = 'absolute'; msg.style.top = '40%'; msg.style.left = '50%'; msg.style.transform = 'translate(-50%, -50%)'; msg.style.background = 'rgba(0,0,0,0.8)'; msg.style.color = 'white'; msg.style.padding = '8px 16px'; msg.style.borderRadius = '8px'; msg.style.zIndex = '1000'; msg.style.fontSize = '1.1em'; document.body.appendChild(msg); // --- Overlay click cancels the add operation --- overlay.addEventListener('click', function cancelAddFruit() { // Remove highlight/selectable from categories document.querySelectorAll('.categories').forEach(cat => { cat.classList.remove('category-selectable'); cat.removeEventListener('click', onCategoryClick, true); }); // Remove overlay and message document.body.removeChild(msg); overlay.style.opacity = '0'; setTimeout(() => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }, 300); // Restore the square to its original state (just the number) squareEl.innerHTML = ''; const numDiv = document.createElement('div'); numDiv.className = 'square-number'; numDiv.textContent = squareNumber; squareEl.appendChild(numDiv); }); // Handler for category click function onCategoryClick(e) { e.stopPropagation(); document.querySelectorAll('.categories').forEach(cat => { cat.classList.remove('category-selectable'); cat.removeEventListener('click', onCategoryClick, true); }); document.body.removeChild(msg); // --- Remove overlay with fade-out --- if (overlay && overlay.parentNode) { overlay.style.opacity = '0'; setTimeout(() => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }, 300); } // --- end remove overlay --- // Find category name const catHeader = e.currentTarget.querySelector('.category-header h4'); const categoryName = catHeader ? catHeader.textContent : null; if (!categoryName) return; // Send to server to add fruit to category fetch('/api/add-fruit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ category: categoryName, fruit: customName }) }) .then(res => res.json()) .then(async data => { if (data.success) { // Reload categories in sidebar if (window.loadCategories) await window.loadCategories(); initializeFruitAndCategoryEvents(); // Place the fruit in the square placeFruitInSquare(squareEl, customName); // Save the new fruit's position to the database const squareId = squareEl.dataset.squareId; await fetch('/api/positions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ squareId, fruit: customName }), }); } else { alert('Could not add fruit: ' + (data.error || 'Unknown error')); // Restore square squareEl.innerHTML = ''; const numDiv = document.createElement('div'); numDiv.className = 'square-number'; numDiv.textContent = squareNumber; squareEl.appendChild(numDiv); } }); } // Listen for click on any category (use capture to get before toggle) document.querySelectorAll('.categories').forEach(cat => { cat.addEventListener('click', onCategoryClick, true); }); } // Wrap all fruit/category event logic in a function so it can be called after dynamic loading function initializeFruitAndCategoryEvents() { // initialize drag & drop on fruits document.querySelectorAll('.fruit').forEach(el => { el.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', el.dataset.fruit); const dragImg = createDragImage(el); e.dataTransfer.setDragImage(dragImg, dragImg.offsetWidth / 2, dragImg.offsetHeight / 2); // cleanup after drag el.addEventListener('dragend', () => { dragImg.remove(); }, { once: true }); }); // updated mouseover event el.addEventListener('mouseover', e => { const parentCategory = el.closest('.categories'); if (parentCategory) { // Unhighlight all squares for fruits in this category first parentCategory.querySelectorAll('.fruit').forEach(sib => { const sq = document.querySelector(`.square[data-fruit="${sib.dataset.fruit}"]`); if (sq) sq.classList.remove('highlight'); }); } // Now highlight only the hovered fruit's square const square = document.querySelector(`.square[data-fruit="${el.dataset.fruit}"]`); if (square) square.classList.add('highlight'); updateHighlightedBorders(); }); // updated mouseout event el.addEventListener('mouseout', e => { // Remove highlight from the hovered fruit's square const square = document.querySelector(`.square[data-fruit="${el.dataset.fruit}"]`); if (square) square.classList.remove('highlight'); // If still inside the category container, restore highlighting to all fruits there const parentCategory = el.closest('.categories'); if (parentCategory && parentCategory.contains(e.relatedTarget)) { parentCategory.querySelectorAll('.fruit').forEach(sib => { const sq = document.querySelector(`.square[data-fruit="${sib.dataset.fruit}"]`); if (sq) sq.classList.add('highlight'); }); } updateHighlightedBorders(); }); // Add click handler for delete popup (sidebar only) el.addEventListener('click', function(e) { // Only trigger for sidebar fruits (not dropped-fruit) if (!el.classList.contains('fruit') || el.classList.contains('dropped-fruit')) return; // Find category name const catDiv = el.closest('.categories'); if (!catDiv) return; const catHeader = catDiv.querySelector('.category-header h4'); const categoryName = catHeader ? catHeader.textContent : null; if (!categoryName) return; showDeleteFruitPopup(el.dataset.fruit, categoryName); e.stopPropagation(); }); }); // make each square a drop target document.querySelectorAll('.square').forEach(sq => { sq.addEventListener('dragover', e => e.preventDefault()); sq.addEventListener('drop', async e => { e.preventDefault(); const fruit = e.dataTransfer.getData('text/plain'); // check if the fruit is already placed in another square const existingSquare = document.querySelector(`.square[data-fruit="${fruit}"]`); if (existingSquare && existingSquare !== sq) { existingSquare.innerHTML = ''; delete existingSquare.dataset.fruit; const existingSquareId = existingSquare.dataset.squareId; await fetch('/api/positions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ squareId: existingSquareId, fruit: '' }), }); } placeFruitInSquare(sq, fruit); // save to server const squareId = sq.dataset.squareId; await fetch('/api/positions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ squareId, fruit }), }); }); // Add double-click handler for custom fruit sq.addEventListener('dblclick', e => { // Only allow if square is empty (no fruit) if (!sq.dataset.fruit && !sq.querySelector('.dropped-fruit')) { showCustomFruitInput(sq); } }); }); // add highlighting for squares based on category hover document.querySelectorAll('.categories').forEach(category => { category.addEventListener('mouseenter', () => { category.querySelectorAll('.fruit').forEach(fruitEl => { const fruitName = fruitEl.dataset.fruit; const square = document.querySelector(`.square[data-fruit="${fruitName}"]`); if (square) { square.classList.add('highlight'); } }); updateHighlightedBorders(); }); category.addEventListener('mouseleave', () => { category.querySelectorAll('.fruit').forEach(fruitEl => { const fruitName = fruitEl.dataset.fruit; const square = document.querySelector(`.square[data-fruit="${fruitName}"]`); if (square) { square.classList.remove('highlight'); } }); updateHighlightedBorders(); }); }); // Collapsible categories logic document.querySelectorAll('.categories').forEach(category => { const toggleBtn = category.querySelector('.category-header .category-toggle'); const content = category.querySelector('.category-content'); if (toggleBtn && content) { toggleBtn.addEventListener('click', () => { const isOpen = content.style.display !== 'none'; content.style.display = isOpen ? 'none' : ''; toggleBtn.textContent = isOpen ? '►' : '▼'; }); } }); } // on load, fetch saved positions and render window.addEventListener('DOMContentLoaded', async () => { try { // Add square numbers to all squares on initial load document.querySelectorAll('.square').forEach(sq => { // Only add if not already present (avoid duplicates) if (!sq.querySelector('.square-number')) { const numDiv = document.createElement('div'); numDiv.className = 'square-number'; numDiv.textContent = sq.dataset.squareId; sq.appendChild(numDiv); } }); const res = await fetch('/api/positions'); const mapping = await res.json(); for (const [squareId, fruit] of Object.entries(mapping)) { const sq = document.querySelector( `.square[data-square-id="${squareId}"]` ); if (sq && fruit) { placeFruitInSquare(sq, fruit); } } } catch (err) { console.error('Could not load saved positions', err); } // After categories are loaded, initialize events if (window.loadCategories) { await window.loadCategories(); initializeFruitAndCategoryEvents(); } else { initializeFruitAndCategoryEvents(); } }); // this is the stuff that let's you drag the map around and zoom in and out (() => { const mapWrapper = document.getElementById('mapWrapper'); const map = document.getElementById('map'); let isDragging = false; let startX, startY, startPanX, startPanY; let panX = 0, panY = 0; let currentScale = 1; const minScale = 0.5, maxScale = 3; function updateTransform() { map.style.transform = `translate(${panX}px, ${panY}px) scale(${currentScale})`; } mapWrapper.addEventListener('mousedown', (e) => { if (e.target.closest('.fruit') || e.target.closest('.dropped-fruit')) return; isDragging = true; startX = e.clientX; startY = e.clientY; startPanX = panX; startPanY = panY; map.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const deltaX = e.clientX - startX; const deltaY = e.clientY - startY; panX = startPanX + deltaX; panY = startPanY + deltaY; updateTransform(); }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; map.style.cursor = 'grab'; } }); // mouse wheel zoom and smooth zoom mapWrapper.addEventListener('wheel', (e) => { e.preventDefault(); const sensitivity = 0.001; // adjust sensitivity of zoom // calculate new scale smoothly using exponential curve let newScale = currentScale * Math.exp(-e.deltaY * sensitivity); // clamp to allowed values newScale = Math.min(maxScale, Math.max(minScale, newScale)); const zoomFactor = newScale / currentScale; // Get the mouse position relative to the mapWrapper const rect = mapWrapper.getBoundingClientRect(); const offsetX = e.clientX - rect.left; const offsetY = e.clientY - rect.top; // Adjust panX and panY so the zoom is centered on the pointer panX = offsetX - zoomFactor * (offsetX - panX); panY = offsetY - zoomFactor * (offsetY - panY); currentScale = newScale; updateTransform(); }); // new function for smooth zoom animation function animateZoom(targetScale, targetPanX, targetPanY, duration = 300) { const startScale = currentScale; const startPanX = panX; const startPanY = panY; const startTime = performance.now(); function step(now) { const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); // progress from 0 to 1 (linear easing) currentScale = startScale + (targetScale - startScale) * progress; panX = startPanX + (targetPanX - startPanX) * progress; panY = startPanY + (targetPanY - startPanY) * progress; updateTransform(); if (progress < 1) { requestAnimationFrame(step); } } requestAnimationFrame(step); } // zoom button handlers using smooth animation const zoomInBtn = document.getElementById('zoomIn'); const zoomOutBtn = document.getElementById('zoomOut'); const zoomButtonHandler = (zoomFactor) => { let targetScale = currentScale * zoomFactor; targetScale = Math.min(maxScale, Math.max(minScale, targetScale)); // calculate the actual zoom factor applied. const actualFactor = targetScale / currentScale; // use the center of the mapWrapper as the zoom origin. const rect = mapWrapper.getBoundingClientRect(); const centerX = rect.width / 2; const centerY = rect.height / 2; const targetPanX = centerX - actualFactor * (centerX - panX); const targetPanY = centerY - actualFactor * (centerY - panY); animateZoom(targetScale, targetPanX, targetPanY, 300); // change this for the zoom animation time }; zoomInBtn.addEventListener('click', () => { zoomButtonHandler(1.2); }); zoomOutBtn.addEventListener('click', () => { zoomButtonHandler(0.8); }); })(); /* this is a helper to update highlighted square borders. for each highlighted square, if a neighboring square is also highlighted, set the border color on that touching side to transparent. this makes it look like the squares are connected. */ function updateHighlightedBorders() { const squares = document.querySelectorAll('.square'); squares.forEach(sq => { // Reset any inline style and data if not highlighted if (!sq.classList.contains('highlight')) { sq.style.borderLeftColor = ''; sq.style.borderRightColor = ''; sq.style.borderTopColor = ''; sq.style.borderBottomColor = ''; sq.removeAttribute('data-glow-connected'); return; } // set all borders to the highlight color sq.style.borderLeftColor = '#d103f9'; sq.style.borderRightColor = '#d103f9'; sq.style.borderTopColor = '#d103f9'; sq.style.borderBottomColor = '#d103f9'; // find the parent row and index const parentRow = sq.parentElement; // .map-row const cells = Array.from(parentRow.children); const idx = cells.indexOf(sq); // for top and bottom, get the row index from map (#map container) const map = parentRow.parentElement; const rows = Array.from(map.children); const rowIndex = rows.indexOf(parentRow); // Track which sides are connected let connected = ''; // check left neighbor in the same row if (idx > 0 && cells[idx - 1].classList.contains('square') && cells[idx - 1].classList.contains('highlight')) { sq.style.borderLeftColor = 'transparent'; connected += 'L'; } // check right neighbor in the same row if (idx < cells.length - 1 && cells[idx + 1].classList.contains('square') && cells[idx + 1].classList.contains('highlight')) { sq.style.borderRightColor = 'transparent'; connected += 'R'; } // check top neighbor if (rowIndex > 0) { const topRow = rows[rowIndex - 1]; const topCells = Array.from(topRow.children); if (topCells[idx] && topCells[idx].classList.contains('square') && topCells[idx].classList.contains('highlight')) { sq.style.borderTopColor = 'transparent'; connected += 'T'; } } // check bottom neighbor if (rowIndex < rows.length - 1) { const bottomRow = rows[rowIndex + 1]; const bottomCells = Array.from(bottomRow.children); if (bottomCells[idx] && bottomCells[idx].classList.contains('square') && bottomCells[idx].classList.contains('highlight')) { sq.style.borderBottomColor = 'transparent'; connected += 'B'; } } // Set a data attribute for CSS to use for glow masking sq.setAttribute('data-glow-connected', connected); }); } // add Socket.io client side for real time updates const socket = io(); socket.on('update', data => { // find the square with the given squareId const sq = document.querySelector(`.square[data-square-id="${data.squareId}"]`); if (sq) { if (data.fruit) { if (sq.dataset.fruit !== data.fruit) { placeFruitInSquare(sq, data.fruit); } } else { sq.innerHTML = ''; delete sq.dataset.fruit; // re-add the square number after clearing const numDiv = document.createElement('div'); numDiv.className = 'square-number'; numDiv.textContent = sq.dataset.squareId; sq.appendChild(numDiv); } // add highlight and remove after 3 seconds sq.classList.add('highlight-update'); setTimeout(() => { sq.classList.remove('highlight-update'); }, 1000); } }); // Helper to show a delete confirmation popup for a fruit function showDeleteFruitPopup(fruitName, categoryName) { // Remove any existing popup const existing = document.getElementById('delete-fruit-popup'); if (existing) existing.remove(); const popup = document.createElement('div'); popup.id = 'delete-fruit-popup'; popup.style.position = 'fixed'; popup.style.top = '50%'; popup.style.left = '50%'; popup.style.transform = 'translate(-50%, -50%)'; popup.style.background = '#222'; popup.style.color = 'white'; popup.style.padding = '24px 32px'; popup.style.borderRadius = '12px'; popup.style.boxShadow = '0 4px 32px #000b'; popup.style.zIndex = '2000'; popup.style.textAlign = 'center'; popup.innerHTML = `
Remove "${fruitName}" from ${categoryName}?
`; document.body.appendChild(popup); document.getElementById('delete-fruit-cancel').onclick = () => popup.remove(); document.getElementById('delete-fruit-confirm').onclick = async () => { // Send delete request await fetch('/api/delete-fruit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ category: categoryName, fruit: fruitName }) }); popup.remove(); if (window.loadCategories) await window.loadCategories(); initializeFruitAndCategoryEvents(); // Remove fruit from any square if present document.querySelectorAll(`.square[data-fruit="${fruitName}"]`).forEach(sq => { sq.innerHTML = ''; delete sq.dataset.fruit; // re-add the square number after clearing const numDiv = document.createElement('div'); numDiv.className = 'square-number'; numDiv.textContent = sq.dataset.squareId; sq.appendChild(numDiv); }); }; }