// 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 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; const squareId = squareEl.dataset.squareId; await fetch('/api/positions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ squareId, fruit: '' }), }); }); squareEl.appendChild(clearBtn); } // 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(); }); }); // 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 }), }); }); }); // on load, fetch saved positions and render window.addEventListener('DOMContentLoaded', async () => { try { 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); } }); // 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); }); })(); // 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(); }); }); /* 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; } // add highlight and remove after 3 seconds sq.classList.add('highlight-update'); setTimeout(() => { sq.classList.remove('highlight-update'); }, 1000); } }); // 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 ? '►' : '▼'; }); } });