// Arc-based drag system for Go90 Scale console.log("Arc drag system loaded"); // Arc configuration const ARC_CONFIG = { centerX: 0, // Will be set based on canvas width centerY: 0, // Will be set based on canvas height radius: 300, startAngle: -Math.PI / 2, // -90° (top, 0 rating) endAngle: 0, // 0° (right, 90 rating) baseDistance: 50, // Base distance from arc for service icons crowdingOffset: 120, // Distance to push out for each overlapping service iconSize: 80, }; let canvas, ctx, canvasContainer; let servicePositions = {}; // Store { domain: { rating, angle, distance } } let currentDrag = null; function initDragAndDrop() { console.log("initDragAndDrop called - arc mode"); canvasContainer = document.getElementById("dragCanvas"); canvas = document.getElementById("arcCanvas"); ctx = canvas.getContext("2d"); // Set canvas size resizeCanvas(); window.addEventListener("resize", resizeCanvas); // Draw the arc scale drawArcScale(); // Setup service items for dragging const serviceItems = document.querySelectorAll(".service-item"); serviceItems.forEach(setupServiceDrag); } function resizeCanvas() { const rect = canvasContainer.getBoundingClientRect(); canvas.width = rect.width; canvas.height = rect.height; // Set arc center ARC_CONFIG.centerX = canvas.width / 2; ARC_CONFIG.centerY = canvas.height - 100; drawArcScale(); redrawServices(); } function drawArcScale() { if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); const { centerX, centerY, radius, startAngle, endAngle } = ARC_CONFIG; // Draw gradient arc const gradient = ctx.createLinearGradient( centerX, centerY + radius * Math.sin(startAngle), centerX + radius * Math.cos(endAngle), centerY + radius * Math.sin(endAngle), ); gradient.addColorStop(0, "#32cd32"); gradient.addColorStop(0.2, "#adff2f"); gradient.addColorStop(0.4, "#ffff00"); gradient.addColorStop(0.6, "#ffa500"); gradient.addColorStop(0.8, "#ff4500"); gradient.addColorStop(1, "#8b0000"); ctx.strokeStyle = gradient; ctx.lineWidth = 6; ctx.beginPath(); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.stroke(); // Draw tick marks and labels const totalRatings = 91; // 0 to 90 for (let rating = 0; rating <= 90; rating += 10) { const angle = ratingToAngle(rating); const tickLength = rating % 10 === 0 ? 20 : 10; // Outer point on arc const x1 = centerX + radius * Math.cos(angle); const y1 = centerY + radius * Math.sin(angle); // Inner point (tick mark) const x2 = centerX + (radius - tickLength) * Math.cos(angle); const y2 = centerY + (radius - tickLength) * Math.sin(angle); ctx.strokeStyle = "white"; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); // Draw rating label every 10 if (rating % 10 === 0) { const labelDistance = radius - 50; const labelX = centerX + labelDistance * Math.cos(angle); const labelY = centerY + labelDistance * Math.sin(angle); ctx.fillStyle = rating === 90 ? "#ff5555" : "white"; ctx.font = "bold 32px 'Innovator Grotesk', sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(rating.toString(), labelX, labelY); } } // Draw indicator lines for placed services Object.entries(servicePositions).forEach(([domain, data]) => { drawIndicatorLine(data.angle, data.distance); }); } function drawIndicatorLine(angle, distance) { const { centerX, centerY, radius } = ARC_CONFIG; // Point on arc const arcX = centerX + radius * Math.cos(angle); const arcY = centerY + radius * Math.sin(angle); // Point at service icon const iconX = centerX + distance * Math.cos(angle); const iconY = centerY + distance * Math.sin(angle); ctx.strokeStyle = "white"; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.moveTo(arcX, arcY); ctx.lineTo(iconX, iconY); ctx.stroke(); ctx.setLineDash([]); } function ratingToAngle(rating) { const { startAngle, endAngle } = ARC_CONFIG; const t = rating / 90; return startAngle + t * (endAngle - startAngle); } function angleToRating(angle) { const { startAngle, endAngle } = ARC_CONFIG; const t = (angle - startAngle) / (endAngle - startAngle); return Math.max(0, Math.min(90, Math.round(t * 90))); } function setupServiceDrag(element) { element.style.cursor = "grab"; element.addEventListener("mousedown", startDrag); } function startDrag(e) { const serviceItem = e.target.closest(".service-item, .service-on-canvas"); if (!serviceItem) return; e.preventDefault(); const domain = serviceItem.dataset.domain || serviceItem.dataset.canvasDomain; const name = serviceItem.dataset.name; currentDrag = { domain, name, element: serviceItem, isFromCanvas: serviceItem.classList.contains("service-on-canvas"), }; serviceItem.style.opacity = "0.5"; document.addEventListener("mousemove", onDragMove); document.addEventListener("mouseup", onDragEnd); } function onDragMove(e) { if (!currentDrag) return; // Update visual feedback based on mouse position relative to arc const canvasRect = canvas.getBoundingClientRect(); const mouseX = e.clientX - canvasRect.left; const mouseY = e.clientY - canvasRect.top; // Calculate angle from center to mouse const dx = mouseX - ARC_CONFIG.centerX; const dy = mouseY - ARC_CONFIG.centerY; const angle = Math.atan2(dy, dx); // Constrain to arc range const { startAngle, endAngle } = ARC_CONFIG; const constrainedAngle = Math.max(startAngle, Math.min(endAngle, angle)); // Show preview by updating element position if it's on canvas if (currentDrag.isFromCanvas) { const distance = calculateDistance(currentDrag.domain, constrainedAngle); const x = ARC_CONFIG.centerX + distance * Math.cos(constrainedAngle); const y = ARC_CONFIG.centerY + distance * Math.sin(constrainedAngle); currentDrag.element.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; currentDrag.element.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; } } function onDragEnd(e) { if (!currentDrag) return; document.removeEventListener("mousemove", onDragMove); document.removeEventListener("mouseup", onDragEnd); const { domain, name, element, isFromCanvas } = currentDrag; // Check if dropped on services bar (to remove rating) const servicesBar = document.getElementById("servicesBar"); const servicesBarRect = servicesBar.getBoundingClientRect(); if ( e.clientY >= servicesBarRect.top && e.clientY <= servicesBarRect.bottom && e.clientX >= servicesBarRect.left && e.clientX <= servicesBarRect.right ) { // Remove from canvas and add back to bar removeServiceFromCanvas(domain, name); element.style.opacity = "1"; currentDrag = null; return; } // Calculate position on arc const canvasRect = canvas.getBoundingClientRect(); const mouseX = e.clientX - canvasRect.left; const mouseY = e.clientY - canvasRect.top; const dx = mouseX - ARC_CONFIG.centerX; const dy = mouseY - ARC_CONFIG.centerY; const angle = Math.atan2(dy, dx); // Constrain to arc range const { startAngle, endAngle } = ARC_CONFIG; const constrainedAngle = Math.max(startAngle, Math.min(endAngle, angle)); const rating = angleToRating(constrainedAngle); // Place on canvas placeServiceOnArc(domain, name, rating); element.style.opacity = "1"; currentDrag = null; } function calculateDistance(domain, angle) { // Find the furthest "valence ring" occupied within 5 degrees BEFORE this angle // This prevents recursive offsetting - only look backwards along the arc // Only services at the current ring level count as crowding const angularTolerance = 10 * (Math.PI / 180); // 5 degrees in radians const baseDistance = ARC_CONFIG.radius + ARC_CONFIG.baseDistance; // Find all occupied rings in the angular range let occupiedRings = new Set(); Object.entries(servicePositions).forEach(([d, data]) => { if (d !== domain) { const angleDiff = angle - data.angle; // Positive if data.angle is before this angle if (angleDiff >= 0 && angleDiff <= angularTolerance) { // Found a service in the 5 degrees before occupiedRings.add(data.distance); } } }); // Find the first unoccupied ring let testDistance = baseDistance; while (occupiedRings.has(testDistance)) { testDistance += ARC_CONFIG.crowdingOffset; } return testDistance; } function placeServiceOnArc(domain, name, rating) { const angle = ratingToAngle(rating); const distance = calculateDistance(domain, angle); // Store position servicePositions[domain] = { rating, angle, distance }; // Store in pending ratings if (typeof pendingRatings !== "undefined") { pendingRatings[domain] = { domain, name, rating }; } // Remove from services bar const servicesBar = document.getElementById("servicesBar"); const inBar = servicesBar.querySelector(`[data-domain="${domain}"]`); if (inBar) inBar.remove(); // Remove existing on canvas const existing = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); if (existing) existing.remove(); // Calculate pixel position const x = ARC_CONFIG.centerX + distance * Math.cos(angle); const y = ARC_CONFIG.centerY + distance * Math.sin(angle); // Create element const el = document.createElement("div"); el.className = "service-on-canvas"; el.dataset.canvasDomain = domain; el.dataset.domain = domain; el.dataset.name = name; el.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; el.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; el.innerHTML = ` ${name}
${rating}
`; setupServiceDrag(el); canvasContainer.appendChild(el); // Redraw canvas to show indicator line drawArcScale(); // Reposition any crowded services repositionCrowdedServices(); if (typeof updateSaveButton !== "undefined") { updateSaveButton(); } } function repositionCrowdedServices() { // Recalculate distances for all services to handle crowding Object.entries(servicePositions).forEach(([domain, data]) => { const newDistance = calculateDistance(domain, data.angle); if (newDistance !== data.distance) { data.distance = newDistance; // Update element position const el = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); if (el) { const x = ARC_CONFIG.centerX + newDistance * Math.cos(data.angle); const y = ARC_CONFIG.centerY + newDistance * Math.sin(data.angle); el.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; el.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; } } }); drawArcScale(); } function removeServiceFromCanvas(domain, name) { // Remove from positions delete servicePositions[domain]; // Remove from canvas const existing = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); if (existing) existing.remove(); // Add back to services bar const servicesBar = document.getElementById("servicesBar"); const rating = window.existingRatings ? window.existingRatings[domain] : undefined; const serviceEl = document.createElement("div"); serviceEl.className = "service-item"; serviceEl.dataset.domain = domain; serviceEl.dataset.name = name; serviceEl.innerHTML = ` ${rating !== undefined ? `
${rating}
` : ""} `; setupServiceDrag(serviceEl); servicesBar.appendChild(serviceEl); // Remove from pending if (typeof pendingRatings !== "undefined") { delete pendingRatings[domain]; } // Redraw and reposition drawArcScale(); repositionCrowdedServices(); if (typeof updateSaveButton !== "undefined") { updateSaveButton(); } } function redrawServices() { // Redraw all services at their current positions Object.entries(servicePositions).forEach(([domain, data]) => { const el = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); if (el) { const x = ARC_CONFIG.centerX + data.distance * Math.cos(data.angle); const y = ARC_CONFIG.centerY + data.distance * Math.sin(data.angle); el.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; el.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; } }); }