inital commit

This commit is contained in:
2025-05-28 04:04:41 -07:00
commit c7ee97d6a9
9 changed files with 3263 additions and 0 deletions

375
public/script.js Normal file
View File

@@ -0,0 +1,375 @@
// 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 if not highlighted
if (!sq.classList.contains('highlight')) {
sq.style.borderLeftColor = '';
sq.style.borderRightColor = '';
sq.style.borderTopColor = '';
sq.style.borderBottomColor = '';
return;
}
// set all borders to the highlight color. change this to change the 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);
// 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';
}
// 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';
}
// 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);
// check top neighbor (like same cell index in previous row)
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';
}
}
// check bottom neighbor (like same cell index in next row)
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';
}
}
});
}
// 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);
}
});