/** * Utility module for processing and populating the treasure map SVG. */ // Global variable to track if we've added our custom styles let stylesAdded = false; /** * Adds custom styles to the document head for clue points */ function addCustomStyles() { if (stylesAdded) return; const style = document.createElement("style"); style.id = "map-custom-styles"; style.textContent = ` @keyframes cluePointPulse { 0% { stroke-width: 3px; transform: scale(1); filter: drop-shadow(0px 0px 3px rgba(255, 152, 0, 0.9)); } 50% { stroke-width: 6px; transform: scale(1.2); filter: drop-shadow(0px 0px 10px rgba(255, 152, 0, 1)); } 100% { stroke-width: 3px; transform: scale(1); filter: drop-shadow(0px 0px 3px rgba(255, 152, 0, 0.9)); } } /* Force all SVG elements to be transparent to mouse events by default */ svg * { pointer-events: none; } /* Create a stacking context for the overlay */ .clue-points-overlay { pointer-events: all !important; position: relative !important; z-index: 9999 !important; } /* Make clue points highly visible with strong styling */ .clue-point { r: 14 !important; cursor: pointer !important; stroke-width: 3px !important; pointer-events: all !important; z-index: 9999 !important; position: relative !important; fill-opacity: 1 !important; stroke-opacity: 1 !important; filter: drop-shadow(0px 0px 3px rgba(0, 0, 0, 0.7)) !important; } .clue-point:hover { fill: #ffb74d !important; stroke: #ff6d00 !important; stroke-width: 5px !important; transform: scale(1.3) !important; z-index: 10000 !important; filter: drop-shadow(0px 0px 6px rgba(0, 0, 0, 0.8)) !important; } .clue-point-found { fill: #2e7d32 !important; stroke: #1b5e20 !important; } .clue-point-current { fill: #ff9800 !important; stroke: #e65100 !important; animation: cluePointPulse 2s infinite !important; } .clue-point-skipped { fill: #1976d2 !important; stroke: #0d47a1 !important; } .clue-point-locked { fill: #9e9e9e !important; stroke: #757575 !important; } .clue-click-area { opacity: 0.01; fill: transparent !important; stroke: transparent !important; pointer-events: all !important; cursor: pointer !important; z-index: 9998 !important; } `; document.head.appendChild(style); stylesAdded = true; console.log("Custom map styles added to document"); } /** * Populates the SVG map with clue points * @param {SVGElement} svgElement - The SVG DOM element to process * @returns {Promise} - Success flag */ export async function populateMap(svgElement) { try { console.log("Starting map population process"); // Add custom styles to document if not already added addCustomStyles(); if (!svgElement) { console.error("No SVG element provided"); return false; } // Clear any existing clue points and click areas to avoid duplicates const existingCluePoints = svgElement.querySelectorAll( ".clue-point, .clue-click-area, .clue-points-overlay" ); existingCluePoints.forEach((element) => { if (element.parentNode) { element.parentNode.removeChild(element); } }); console.log("Cleared existing clue points"); // Find all circles in the SVG const allCircles = Array.from(svgElement.querySelectorAll("circle")); console.log(`Found ${allCircles.length} circles in SVG`); if (allCircles.length === 0) { console.error("No circles found in SVG. Creating fallback circles."); // Create some fallback circles at predefined positions const defs = document.createElementNS( "http://www.w3.org/2000/svg", "defs" ); const positions = [ { x: 100, y: 100 }, { x: 200, y: 150 }, { x: 300, y: 200 }, { x: 400, y: 300 }, { x: 500, y: 400 }, { x: 600, y: 500 }, ]; positions.forEach((pos, i) => { const circle = document.createElementNS( "http://www.w3.org/2000/svg", "circle" ); circle.setAttribute("cx", pos.x.toString()); circle.setAttribute("cy", pos.y.toString()); circle.setAttribute("r", "5"); circle.setAttribute("fill", "transparent"); defs.appendChild(circle); }); // Add the defs to the SVG svgElement.appendChild(defs); // Now find all circles again const fallbackCircles = Array.from(defs.querySelectorAll("circle")); if (fallbackCircles.length > 0) { console.log(`Created ${fallbackCircles.length} fallback circles`); return await processCircles(svgElement, fallbackCircles); } else { return false; } } else { return await processCircles(svgElement, allCircles); } } catch (error) { console.error("Error populating map:", error); return false; } } /** * Process circles to create clue points * @param {SVGElement} svgElement - The SVG DOM element * @param {Array} circles - Array of circle elements * @returns {Promise} - Success flag */ async function processCircles(svgElement, circles) { try { // Hide original circles circles.forEach((circle) => (circle.style.display = "none")); // Fetch the number of clues from the API const response = await fetch("/api/clues/count", { method: "GET", credentials: "include", headers: { "Cache-Control": "no-cache, no-store, must-revalidate", Pragma: "no-cache", }, }); if (!response.ok) { throw new Error(`Failed to fetch clue count: ${response.status}`); } const data = await response.json(); const clueCount = parseInt(data.count, 10) || 0; console.log(`API returned clue count: ${clueCount}`); if (clueCount <= 0) { console.error("Invalid clue count returned from API, using default of 5"); clueCount = 5; // Fallback to 5 clues if API returns invalid data } // Sort circles by vertical position (top to bottom) const sortedCircles = circles.sort((a, b) => { const aY = parseFloat(a.getAttribute("cy") || "0"); const bY = parseFloat(b.getAttribute("cy") || "0"); return aY - bY; }); // Determine how many circles to show const circlesToShow = Math.min(sortedCircles.length, clueCount); console.log(`Will create ${circlesToShow} clue points from sorted circles`); // Create the overlay element that will go on top of everything const svgNamespace = "http://www.w3.org/2000/svg"; // Remove any old overlay group const oldOverlay = svgElement.querySelector(".clue-points-overlay"); if (oldOverlay && oldOverlay.parentNode) { oldOverlay.parentNode.removeChild(oldOverlay); } // Create a new overlay group that will be the last child of the SVG // so it renders on top of everything else const overlayGroup = document.createElementNS(svgNamespace, "g"); overlayGroup.setAttribute("class", "clue-points-overlay"); overlayGroup.setAttribute( "style", "pointer-events: all !important; z-index: 9999 !important;" ); // Add the overlay group as the LAST element in the SVG to ensure proper rendering order svgElement.appendChild(overlayGroup); // Create interactive clue points for (let i = 0; i < circlesToShow; i++) { const originalCircle = sortedCircles[i]; // Get original circle attributes const cx = originalCircle.getAttribute("cx") || "0"; const cy = originalCircle.getAttribute("cy") || "0"; const r = Math.max( parseFloat(originalCircle.getAttribute("r") || "5"), 14 ); // Create a click area (larger invisible circle for easier clicking) const clickArea = document.createElementNS(svgNamespace, "circle"); clickArea.setAttribute("cx", cx); clickArea.setAttribute("cy", cy); clickArea.setAttribute("r", (r * 2).toString()); clickArea.setAttribute("class", "clue-click-area"); clickArea.setAttribute("data-clue-id", (i + 1).toString()); clickArea.setAttribute("data-index", i.toString()); clickArea.setAttribute( "style", "opacity: 0.01; pointer-events: all !important; cursor: pointer !important; z-index: 9998 !important;" ); // Create a new visible circle for the clue point with explicit inline styles const cluePoint = document.createElementNS(svgNamespace, "circle"); cluePoint.setAttribute("cx", cx); cluePoint.setAttribute("cy", cy); cluePoint.setAttribute("r", r.toString()); cluePoint.setAttribute("class", "clue-point clue-point-locked"); cluePoint.setAttribute("data-clue-id", (i + 1).toString()); cluePoint.setAttribute("data-index", i.toString()); cluePoint.setAttribute( "style", "fill: #9e9e9e; stroke: #757575; stroke-width: 3px; cursor: pointer; " + "pointer-events: all !important; z-index: 9999 !important; position: relative !important; " + "fill-opacity: 1 !important; stroke-opacity: 1 !important; " + "filter: drop-shadow(0px 0px 3px rgba(0, 0, 0, 0.7));" ); // Add a tooltip for accessibility const tooltip = document.createElementNS(svgNamespace, "title"); tooltip.textContent = `Clue #${i + 1}`; cluePoint.appendChild(tooltip); // Add the new elements to the overlay group overlayGroup.appendChild(clickArea); overlayGroup.appendChild(cluePoint); } // Set explicit width and height on the overlay group to ensure it covers the whole SVG const svgWidth = svgElement.getAttribute("width") || "100%"; const svgHeight = svgElement.getAttribute("height") || "100%"; overlayGroup.setAttribute("width", svgWidth); overlayGroup.setAttribute("height", svgHeight); // Bring the overlay forward by appending it again // This ensures it's the last child of the SVG svgElement.appendChild(overlayGroup); console.log(`Successfully created ${circlesToShow} clue points`); return true; } catch (error) { console.error("Error processing circles:", error); return false; } } /** * Updates the clue points based on the latest clue data * @param {SVGElement} svgElement - The SVG DOM element * @param {Array} clues - Array of clue objects with their status */ export function updateCluePointStatus(svgElement, clues) { try { if (!svgElement || !clues || !Array.isArray(clues) || clues.length === 0) { console.error("Invalid parameters for updateCluePointStatus"); return false; } console.log(`Updating status for ${clues.length} clues`); // Find all clue points const cluePoints = svgElement.querySelectorAll(".clue-point"); const clickAreas = svgElement.querySelectorAll(".clue-click-area"); if (cluePoints.length === 0) { console.error("No clue points found to update"); return false; } console.log( `Found ${cluePoints.length} clue points and ${clickAreas.length} click areas to update` ); // Find the first unfound clue (the current clue) const currentIndex = clues.findIndex((clue) => !clue.found); const currentClueId = currentIndex !== -1 ? clues[currentIndex].id : null; console.log(`Current clue ID: ${currentClueId}`); // Update each clue point's status cluePoints.forEach((point, index) => { if (index >= clues.length) return; const clue = clues[index]; const clueId = clue.id; // Remove all status classes first point.classList.remove( "clue-point-found", "clue-point-current", "clue-point-skipped", "clue-point-locked" ); // Update tooltip const tooltip = point.querySelector("title") || document.createElementNS("http://www.w3.org/2000/svg", "title"); // Determine the clue's status and apply appropriate class if (clue.found) { point.classList.add("clue-point-found"); tooltip.textContent = `Clue ${clueId}: ${clue.title || "Completed"}`; } else if (clueId === currentClueId) { point.classList.add("clue-point-current"); tooltip.textContent = `Current Clue: ${ clue.title || "Go to this location" }`; } else if (clueId < currentClueId) { point.classList.add("clue-point-skipped"); tooltip.textContent = `Skipped Clue ${clueId}: ${ clue.title || "Skipped" }`; } else { point.classList.add("clue-point-locked"); tooltip.textContent = `Locked: Complete previous clues first`; } // Store availability data const isAvailable = clue.found || clueId === currentClueId; point.setAttribute("data-available", isAvailable.toString()); // Make sure tooltip is attached if (!point.contains(tooltip)) { point.appendChild(tooltip); } }); console.log("Clue point status updated successfully"); return true; } catch (error) { console.error("Error updating clue point status:", error); return false; } } /** * Attaches click handlers to clue points * @param {SVGElement} svgElement - The SVG DOM element * @param {Array} clues - Array of clue objects with their status * @param {Function} navigationHandler - Function to handle navigation */ export function attachClueClickHandlers(svgElement, clues, navigationHandler) { try { if (!svgElement || !clues || !navigationHandler) { console.error("Invalid parameters for attachClueClickHandlers"); return false; } // Get all interactive elements (clue points and their click areas) const cluePoints = Array.from(svgElement.querySelectorAll(".clue-point")); const clickAreas = Array.from( svgElement.querySelectorAll(".clue-click-area") ); console.log( `Attaching click handlers to ${cluePoints.length} clue points and ${clickAreas.length} click areas` ); // Find the current active clue const currentIndex = clues.findIndex((clue) => !clue.found); // Helper function to process a click on a clue point const processClueClick = (element) => { const index = parseInt(element.getAttribute("data-index"), 10); if (isNaN(index) || index < 0 || index >= clues.length) { console.error(`Invalid clue index: ${index}`); return; } const clue = clues[index]; const isAvailable = clue.found || index === currentIndex; console.log(`Clue ${clue.id} clicked, available: ${isAvailable}`); if (isAvailable) { // Show click feedback const cluePoint = cluePoints[index]; if (cluePoint) { // Briefly change color for visual feedback const originalClasses = [...cluePoint.classList]; cluePoint.classList.add("click-feedback"); setTimeout(() => { // Navigate to the clue page navigationHandler(clue.id); }, 100); } else { navigationHandler(clue.id); } } else { // Alert about locked clue alert("Complete previous clues to unlock this one!"); } }; // Attach new click handlers to clue points cluePoints.forEach((point) => { // Remove existing handlers point.onclick = null; // Add new click handler point.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); processClueClick(point); }); }); // Attach click handlers to click areas (larger invisible circles) clickAreas.forEach((area) => { // Remove existing handlers area.onclick = null; // Add new click handler - these use the same handler as clue points area.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); processClueClick(area); }); }); console.log("Click handlers attached successfully"); return true; } catch (error) { console.error("Error attaching click handlers:", error); return false; } }