The Go90 Scale of Doomed Streaming Services
at main 13 kB view raw
1// Arc-based drag system for Go90 Scale 2console.log("Arc drag system loaded"); 3 4// Arc configuration 5const ARC_CONFIG = { 6 centerX: 0, // Will be set based on canvas width 7 centerY: 0, // Will be set based on canvas height 8 radius: 300, 9 startAngle: -Math.PI / 2, // -90° (top, 0 rating) 10 endAngle: 0, // 0° (right, 90 rating) 11 baseDistance: 50, // Base distance from arc for service icons 12 crowdingOffset: 120, // Distance to push out for each overlapping service 13 iconSize: 80, 14}; 15 16let canvas, ctx, canvasContainer; 17let servicePositions = {}; // Store { domain: { rating, angle, distance } } 18let currentDrag = null; 19 20function initDragAndDrop() { 21 console.log("initDragAndDrop called - arc mode"); 22 23 canvasContainer = document.getElementById("dragCanvas"); 24 canvas = document.getElementById("arcCanvas"); 25 ctx = canvas.getContext("2d"); 26 27 // Set canvas size 28 resizeCanvas(); 29 window.addEventListener("resize", resizeCanvas); 30 31 // Draw the arc scale 32 drawArcScale(); 33 34 // Setup service items for dragging 35 const serviceItems = document.querySelectorAll(".service-item"); 36 serviceItems.forEach(setupServiceDrag); 37} 38 39function resizeCanvas() { 40 const rect = canvasContainer.getBoundingClientRect(); 41 canvas.width = rect.width; 42 canvas.height = rect.height; 43 44 // Set arc center 45 ARC_CONFIG.centerX = canvas.width / 2; 46 ARC_CONFIG.centerY = canvas.height - 100; 47 48 drawArcScale(); 49 redrawServices(); 50} 51 52function drawArcScale() { 53 if (!ctx) return; 54 55 ctx.clearRect(0, 0, canvas.width, canvas.height); 56 57 const { centerX, centerY, radius, startAngle, endAngle } = ARC_CONFIG; 58 59 // Draw gradient arc 60 const gradient = ctx.createLinearGradient( 61 centerX, 62 centerY + radius * Math.sin(startAngle), 63 centerX + radius * Math.cos(endAngle), 64 centerY + radius * Math.sin(endAngle), 65 ); 66 67 gradient.addColorStop(0, "#32cd32"); 68 gradient.addColorStop(0.2, "#adff2f"); 69 gradient.addColorStop(0.4, "#ffff00"); 70 gradient.addColorStop(0.6, "#ffa500"); 71 gradient.addColorStop(0.8, "#ff4500"); 72 gradient.addColorStop(1, "#8b0000"); 73 74 ctx.strokeStyle = gradient; 75 ctx.lineWidth = 6; 76 ctx.beginPath(); 77 ctx.arc(centerX, centerY, radius, startAngle, endAngle); 78 ctx.stroke(); 79 80 // Draw tick marks and labels 81 const totalRatings = 91; // 0 to 90 82 for (let rating = 0; rating <= 90; rating += 10) { 83 const angle = ratingToAngle(rating); 84 const tickLength = rating % 10 === 0 ? 20 : 10; 85 86 // Outer point on arc 87 const x1 = centerX + radius * Math.cos(angle); 88 const y1 = centerY + radius * Math.sin(angle); 89 90 // Inner point (tick mark) 91 const x2 = centerX + (radius - tickLength) * Math.cos(angle); 92 const y2 = centerY + (radius - tickLength) * Math.sin(angle); 93 94 ctx.strokeStyle = "white"; 95 ctx.lineWidth = 3; 96 ctx.beginPath(); 97 ctx.moveTo(x1, y1); 98 ctx.lineTo(x2, y2); 99 ctx.stroke(); 100 101 // Draw rating label every 10 102 if (rating % 10 === 0) { 103 const labelDistance = radius - 50; 104 const labelX = centerX + labelDistance * Math.cos(angle); 105 const labelY = centerY + labelDistance * Math.sin(angle); 106 107 ctx.fillStyle = rating === 90 ? "#ff5555" : "white"; 108 ctx.font = "bold 32px 'Innovator Grotesk', sans-serif"; 109 ctx.textAlign = "center"; 110 ctx.textBaseline = "middle"; 111 ctx.fillText(rating.toString(), labelX, labelY); 112 } 113 } 114 115 // Draw indicator lines for placed services 116 Object.entries(servicePositions).forEach(([domain, data]) => { 117 drawIndicatorLine(data.angle, data.distance); 118 }); 119} 120 121function drawIndicatorLine(angle, distance) { 122 const { centerX, centerY, radius } = ARC_CONFIG; 123 124 // Point on arc 125 const arcX = centerX + radius * Math.cos(angle); 126 const arcY = centerY + radius * Math.sin(angle); 127 128 // Point at service icon 129 const iconX = centerX + distance * Math.cos(angle); 130 const iconY = centerY + distance * Math.sin(angle); 131 132 ctx.strokeStyle = "white"; 133 ctx.lineWidth = 2; 134 ctx.setLineDash([5, 5]); 135 ctx.beginPath(); 136 ctx.moveTo(arcX, arcY); 137 ctx.lineTo(iconX, iconY); 138 ctx.stroke(); 139 ctx.setLineDash([]); 140} 141 142function ratingToAngle(rating) { 143 const { startAngle, endAngle } = ARC_CONFIG; 144 const t = rating / 90; 145 return startAngle + t * (endAngle - startAngle); 146} 147 148function angleToRating(angle) { 149 const { startAngle, endAngle } = ARC_CONFIG; 150 const t = (angle - startAngle) / (endAngle - startAngle); 151 return Math.max(0, Math.min(90, Math.round(t * 90))); 152} 153 154function setupServiceDrag(element) { 155 element.style.cursor = "grab"; 156 element.addEventListener("mousedown", startDrag); 157} 158 159function startDrag(e) { 160 const serviceItem = e.target.closest(".service-item, .service-on-canvas"); 161 if (!serviceItem) return; 162 163 e.preventDefault(); 164 165 const domain = serviceItem.dataset.domain || serviceItem.dataset.canvasDomain; 166 const name = serviceItem.dataset.name; 167 168 currentDrag = { 169 domain, 170 name, 171 element: serviceItem, 172 isFromCanvas: serviceItem.classList.contains("service-on-canvas"), 173 }; 174 175 serviceItem.style.opacity = "0.5"; 176 document.addEventListener("mousemove", onDragMove); 177 document.addEventListener("mouseup", onDragEnd); 178} 179 180function onDragMove(e) { 181 if (!currentDrag) return; 182 183 // Update visual feedback based on mouse position relative to arc 184 const canvasRect = canvas.getBoundingClientRect(); 185 const mouseX = e.clientX - canvasRect.left; 186 const mouseY = e.clientY - canvasRect.top; 187 188 // Calculate angle from center to mouse 189 const dx = mouseX - ARC_CONFIG.centerX; 190 const dy = mouseY - ARC_CONFIG.centerY; 191 const angle = Math.atan2(dy, dx); 192 193 // Constrain to arc range 194 const { startAngle, endAngle } = ARC_CONFIG; 195 const constrainedAngle = Math.max(startAngle, Math.min(endAngle, angle)); 196 197 // Show preview by updating element position if it's on canvas 198 if (currentDrag.isFromCanvas) { 199 const distance = calculateDistance(currentDrag.domain, constrainedAngle); 200 const x = ARC_CONFIG.centerX + distance * Math.cos(constrainedAngle); 201 const y = ARC_CONFIG.centerY + distance * Math.sin(constrainedAngle); 202 203 currentDrag.element.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; 204 currentDrag.element.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; 205 } 206} 207 208function onDragEnd(e) { 209 if (!currentDrag) return; 210 211 document.removeEventListener("mousemove", onDragMove); 212 document.removeEventListener("mouseup", onDragEnd); 213 214 const { domain, name, element, isFromCanvas } = currentDrag; 215 216 // Check if dropped on services bar (to remove rating) 217 const servicesBar = document.getElementById("servicesBar"); 218 const servicesBarRect = servicesBar.getBoundingClientRect(); 219 220 if ( 221 e.clientY >= servicesBarRect.top && 222 e.clientY <= servicesBarRect.bottom && 223 e.clientX >= servicesBarRect.left && 224 e.clientX <= servicesBarRect.right 225 ) { 226 // Remove from canvas and add back to bar 227 removeServiceFromCanvas(domain, name); 228 element.style.opacity = "1"; 229 currentDrag = null; 230 return; 231 } 232 233 // Calculate position on arc 234 const canvasRect = canvas.getBoundingClientRect(); 235 const mouseX = e.clientX - canvasRect.left; 236 const mouseY = e.clientY - canvasRect.top; 237 238 const dx = mouseX - ARC_CONFIG.centerX; 239 const dy = mouseY - ARC_CONFIG.centerY; 240 const angle = Math.atan2(dy, dx); 241 242 // Constrain to arc range 243 const { startAngle, endAngle } = ARC_CONFIG; 244 const constrainedAngle = Math.max(startAngle, Math.min(endAngle, angle)); 245 246 const rating = angleToRating(constrainedAngle); 247 248 // Place on canvas 249 placeServiceOnArc(domain, name, rating); 250 251 element.style.opacity = "1"; 252 currentDrag = null; 253} 254 255function calculateDistance(domain, angle) { 256 // Find the furthest "valence ring" occupied within 5 degrees BEFORE this angle 257 // This prevents recursive offsetting - only look backwards along the arc 258 // Only services at the current ring level count as crowding 259 const angularTolerance = 10 * (Math.PI / 180); // 5 degrees in radians 260 const baseDistance = ARC_CONFIG.radius + ARC_CONFIG.baseDistance; 261 262 // Find all occupied rings in the angular range 263 let occupiedRings = new Set(); 264 Object.entries(servicePositions).forEach(([d, data]) => { 265 if (d !== domain) { 266 const angleDiff = angle - data.angle; // Positive if data.angle is before this angle 267 if (angleDiff >= 0 && angleDiff <= angularTolerance) { 268 // Found a service in the 5 degrees before 269 occupiedRings.add(data.distance); 270 } 271 } 272 }); 273 274 // Find the first unoccupied ring 275 let testDistance = baseDistance; 276 while (occupiedRings.has(testDistance)) { 277 testDistance += ARC_CONFIG.crowdingOffset; 278 } 279 280 return testDistance; 281} 282 283function placeServiceOnArc(domain, name, rating) { 284 const angle = ratingToAngle(rating); 285 const distance = calculateDistance(domain, angle); 286 287 // Store position 288 servicePositions[domain] = { rating, angle, distance }; 289 290 // Store in pending ratings 291 if (typeof pendingRatings !== "undefined") { 292 pendingRatings[domain] = { domain, name, rating }; 293 } 294 295 // Remove from services bar 296 const servicesBar = document.getElementById("servicesBar"); 297 const inBar = servicesBar.querySelector(`[data-domain="${domain}"]`); 298 if (inBar) inBar.remove(); 299 300 // Remove existing on canvas 301 const existing = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); 302 if (existing) existing.remove(); 303 304 // Calculate pixel position 305 const x = ARC_CONFIG.centerX + distance * Math.cos(angle); 306 const y = ARC_CONFIG.centerY + distance * Math.sin(angle); 307 308 // Create element 309 const el = document.createElement("div"); 310 el.className = "service-on-canvas"; 311 el.dataset.canvasDomain = domain; 312 el.dataset.domain = domain; 313 el.dataset.name = name; 314 el.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; 315 el.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; 316 el.innerHTML = ` 317 <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128" 318 alt="${name}" 319 class="service-icon" 320 draggable="false"> 321 <div class="service-badge ${rating === 90 ? "defunct" : ""}">${rating}</div> 322 `; 323 324 setupServiceDrag(el); 325 canvasContainer.appendChild(el); 326 327 // Redraw canvas to show indicator line 328 drawArcScale(); 329 330 // Reposition any crowded services 331 repositionCrowdedServices(); 332 333 if (typeof updateSaveButton !== "undefined") { 334 updateSaveButton(); 335 } 336} 337 338function repositionCrowdedServices() { 339 // Recalculate distances for all services to handle crowding 340 Object.entries(servicePositions).forEach(([domain, data]) => { 341 const newDistance = calculateDistance(domain, data.angle); 342 if (newDistance !== data.distance) { 343 data.distance = newDistance; 344 345 // Update element position 346 const el = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); 347 if (el) { 348 const x = ARC_CONFIG.centerX + newDistance * Math.cos(data.angle); 349 const y = ARC_CONFIG.centerY + newDistance * Math.sin(data.angle); 350 el.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; 351 el.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; 352 } 353 } 354 }); 355 356 drawArcScale(); 357} 358 359function removeServiceFromCanvas(domain, name) { 360 // Remove from positions 361 delete servicePositions[domain]; 362 363 // Remove from canvas 364 const existing = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); 365 if (existing) existing.remove(); 366 367 // Add back to services bar 368 const servicesBar = document.getElementById("servicesBar"); 369 const rating = window.existingRatings ? window.existingRatings[domain] : undefined; 370 371 const serviceEl = document.createElement("div"); 372 serviceEl.className = "service-item"; 373 serviceEl.dataset.domain = domain; 374 serviceEl.dataset.name = name; 375 serviceEl.innerHTML = ` 376 <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128" 377 alt="${name}" 378 class="service-logo" 379 draggable="false"> 380 ${rating !== undefined ? `<div class="service-badge ${rating === 90 ? "defunct" : ""}">${rating}</div>` : ""} 381 `; 382 setupServiceDrag(serviceEl); 383 servicesBar.appendChild(serviceEl); 384 385 // Remove from pending 386 if (typeof pendingRatings !== "undefined") { 387 delete pendingRatings[domain]; 388 } 389 390 // Redraw and reposition 391 drawArcScale(); 392 repositionCrowdedServices(); 393 394 if (typeof updateSaveButton !== "undefined") { 395 updateSaveButton(); 396 } 397} 398 399function redrawServices() { 400 // Redraw all services at their current positions 401 Object.entries(servicePositions).forEach(([domain, data]) => { 402 const el = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); 403 if (el) { 404 const x = ARC_CONFIG.centerX + data.distance * Math.cos(data.angle); 405 const y = ARC_CONFIG.centerY + data.distance * Math.sin(data.angle); 406 el.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; 407 el.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; 408 } 409 }); 410}