The Go90 Scale of Doomed Streaming Services
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}