mirror of
https://github.com/mudabbir-ahmad/peworkshop.git
synced 2026-10-07 19:50:20 +00:00
581 lines
19 KiB
JavaScript
581 lines
19 KiB
JavaScript
/**
|
|
* 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<boolean>} - 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. Attempting to load from original map."
|
|
);
|
|
|
|
// If no circles in current SVG, try to fetch circles from the original map SVG
|
|
try {
|
|
const mapResponse = await fetch("/images/map.svg");
|
|
if (!mapResponse.ok) {
|
|
throw new Error("Failed to load original map SVG");
|
|
}
|
|
|
|
const mapText = await mapResponse.text();
|
|
const parser = new DOMParser();
|
|
const mapDoc = parser.parseFromString(mapText, "image/svg+xml");
|
|
|
|
// Find circles in the original map
|
|
const originalCircles = Array.from(mapDoc.querySelectorAll("circle"));
|
|
console.log(`Found ${originalCircles.length} circles in original map`);
|
|
|
|
if (originalCircles.length > 0) {
|
|
return await processCircles(svgElement, originalCircles, true);
|
|
} else {
|
|
console.error(
|
|
"No circles found in original map either. Using fallback positions."
|
|
);
|
|
return await createFallbackCircles(svgElement);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load circles from original map:", error);
|
|
return await createFallbackCircles(svgElement);
|
|
}
|
|
} else {
|
|
return await processCircles(svgElement, allCircles, false);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error populating map:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create fallback circles at preset positions as a last resort
|
|
* @param {SVGElement} svgElement - The SVG DOM element
|
|
* @returns {Promise<boolean>} - Success flag
|
|
*/
|
|
async function createFallbackCircles(svgElement) {
|
|
// Create some fallback circles at predefined positions
|
|
const svgNamespace = "http://www.w3.org/2000/svg";
|
|
const defs = document.createElementNS(svgNamespace, "defs");
|
|
const positions = [
|
|
{ x: 100, y: 100 },
|
|
{ x: 200, y: 200 },
|
|
{ x: 300, y: 300 },
|
|
{ x: 400, y: 200 },
|
|
{ x: 500, y: 100 },
|
|
{ x: 600, y: 300 },
|
|
];
|
|
|
|
positions.forEach((pos, i) => {
|
|
const circle = document.createElementNS(svgNamespace, "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, false);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process circles to create clue points
|
|
* @param {SVGElement} svgElement - The SVG DOM element
|
|
* @param {Array} circles - Array of circle elements
|
|
* @param {boolean} isFromOriginalMap - Whether circles are from the original map
|
|
* @returns {Promise<boolean>} - Success flag
|
|
*/
|
|
async function processCircles(svgElement, circles, isFromOriginalMap) {
|
|
try {
|
|
// Don't hide original circles if they are from the original map and not present in current SVG
|
|
if (!isFromOriginalMap) {
|
|
// Remove hiding originals so the new elements are clearly visible
|
|
// 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();
|
|
let 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
|
|
}
|
|
|
|
// Use the circles exactly where they are - preserve original positions
|
|
// Just limit how many we use based on clue count
|
|
const circlesToShow = Math.min(circles.length, clueCount);
|
|
console.log(
|
|
`Will create ${circlesToShow} clue points from available 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 (but leave persistent button layer intact)
|
|
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 based on the original circle positions
|
|
for (let i = 0; i < circlesToShow; i++) {
|
|
const originalCircle = circles[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);
|
|
|
|
// Instead of appending button layer with absolute positioning,
|
|
// create/update it as a child of overlayGroup using normal SVG positioning.
|
|
let buttonGroup = svgElement.querySelector(".clue-button-layer");
|
|
if (buttonGroup) {
|
|
buttonGroup.innerHTML = "";
|
|
} else {
|
|
buttonGroup = document.createElementNS(svgNamespace, "g");
|
|
buttonGroup.setAttribute("class", "clue-button-layer");
|
|
// Remove absolute positioning; allow buttonGroup to inherit the overlay's coordinate system.
|
|
buttonGroup.setAttribute(
|
|
"style",
|
|
"pointer-events: auto; z-index: 10000;"
|
|
);
|
|
}
|
|
for (let i = 0; i < circlesToShow; i++) {
|
|
const originalCircle = circles[i];
|
|
const cx = originalCircle.getAttribute("cx") || "0";
|
|
const cy = originalCircle.getAttribute("cy") || "0";
|
|
const r = Math.max(
|
|
parseFloat(originalCircle.getAttribute("r") || "5"),
|
|
14
|
|
);
|
|
|
|
const button = document.createElementNS(svgNamespace, "circle");
|
|
button.setAttribute("cx", cx);
|
|
button.setAttribute("cy", cy);
|
|
button.setAttribute("r", r.toString());
|
|
button.setAttribute("class", "clue-button");
|
|
button.setAttribute(
|
|
"style",
|
|
"fill: #4caf50; stroke: #388e3c; stroke-width: 2px; cursor: pointer; opacity: 0.9; pointer-events: auto;"
|
|
);
|
|
button.addEventListener("click", () => {
|
|
console.log(`Button for clue ${i + 1} clicked`);
|
|
// ...add navigation or other logic here...
|
|
});
|
|
buttonGroup.appendChild(button);
|
|
}
|
|
// Append or re-append the buttonGroup into the overlayGroup so they appear together.
|
|
overlayGroup.appendChild(buttonGroup);
|
|
|
|
console.log(
|
|
`Successfully created ${circlesToShow} clue points and buttons`
|
|
);
|
|
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 and unskipped clue
|
|
const currentIndex = clues.findIndex(
|
|
(clue) => !clue.found && !clue.skipped
|
|
);
|
|
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 (clue.skipped) {
|
|
// Explicitly mark skipped clues with blue color
|
|
point.classList.add("clue-point-skipped");
|
|
tooltip.textContent = `Skipped Clue ${clueId}: ${
|
|
clue.title || "Skipped"
|
|
}`;
|
|
} else if (clueId === currentClueId) {
|
|
point.classList.add("clue-point-current");
|
|
tooltip.textContent = `Current Clue: ${
|
|
clue.title || "Go to this location"
|
|
}`;
|
|
} else if (clue.available) {
|
|
// Available but not current - could be previous clue or next after skipped
|
|
point.classList.add("clue-point-current");
|
|
tooltip.textContent = `Available Clue ${clueId}: ${
|
|
clue.title || "Available"
|
|
}`;
|
|
} else {
|
|
point.classList.add("clue-point-locked");
|
|
tooltip.textContent = `Locked: Complete previous clues first`;
|
|
}
|
|
|
|
// Store availability data
|
|
const isAvailable =
|
|
clue.found ||
|
|
clue.skipped ||
|
|
clueId === currentClueId ||
|
|
clue.available;
|
|
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;
|
|
}
|
|
}
|