574 lines
19 kB
1
defmodule BlogWeb.CursorTrackerLive do
2
use BlogWeb, :live_view
3
require Logger
4
alias Blog.CursorPoints
5
6
@topic "cursor_tracker"
7
# 60 minutes in seconds
8
@clear_interval 60 * 60
9
10
def mount(_params, _session, socket) do
11
socket =
12
assign(socket,
13
x_pos: 0,
14
y_pos: 0,
15
relative_x: 0,
16
relative_y: 0,
17
in_visualization: false,
18
favorite_points: [],
19
other_users: %{},
20
user_id: nil,
21
user_color: nil,
22
next_clear: calculate_next_clear(),
23
page_title: "Cursor Tracker",
24
meta_attrs: [
25
%{name: "description", content: "Track cursor positions, draw points on a canvas"},
26
%{property: "og:title", content: "Cursor Tracker - Live Shared and here to draw"},
27
%{
28
property: "og:description",
29
content: "Track cursor positions, draw points on a canvas"
30
},
31
%{property: "og:type", content: "website"}
32
]
33
)
34
35
if connected?(socket) do
36
# Generate a unique user ID and color
37
user_id = generate_user_id()
38
user_color = generate_user_color(user_id)
39
40
# Subscribe to the PubSub topic
41
Phoenix.PubSub.subscribe(Blog.PubSub, @topic)
42
43
# Subscribe to presence diff events
44
Phoenix.PubSub.subscribe(Blog.PubSub, "presence:" <> @topic)
45
46
# Track presence
47
{:ok, _} =
48
BlogWeb.Presence.track(
49
self(),
50
@topic,
51
user_id,
52
%{
53
color: user_color,
54
joined_at: DateTime.utc_now(),
55
cursor: %{x: 0, y: 0, in_viz: false}
56
}
57
)
58
59
# Get current users
60
other_users = list_present_users(user_id)
61
62
# Broadcast that we've joined
63
broadcast_join(user_id, user_color)
64
65
# Load shared points
66
shared_points = get_shared_points()
67
68
Process.send_after(self(), :tick, 1000)
69
70
{:ok,
71
assign(socket,
72
user_id: user_id,
73
user_color: user_color,
74
other_users: other_users,
75
favorite_points: shared_points
76
)}
77
else
78
{:ok, socket}
79
end
80
end
81
82
def handle_event("mousemove", %{"x" => x, "y" => y} = params, socket) do
83
# Extract relative coordinates if available
84
relative_x = Map.get(params, "relativeX", 0)
85
relative_y = Map.get(params, "relativeY", 0)
86
in_visualization = Map.get(params, "inVisualization", false)
87
88
# Only broadcast if we have a user_id (connected)
89
if socket.assigns.user_id do
90
# Update presence with new cursor position
91
BlogWeb.Presence.update(
92
self(),
93
@topic,
94
socket.assigns.user_id,
95
fn existing_meta ->
96
Map.put(existing_meta, :cursor, %{
97
x: x,
98
y: y,
99
relative_x: relative_x,
100
relative_y: relative_y,
101
in_viz: in_visualization
102
})
103
end
104
)
105
106
# Broadcast cursor position to all users
107
broadcast_cursor_position(
108
socket.assigns.user_id,
109
socket.assigns.user_color,
110
x,
111
y,
112
relative_x,
113
relative_y,
114
in_visualization
115
)
116
end
117
118
{:noreply,
119
assign(socket,
120
x_pos: x,
121
y_pos: y,
122
relative_x: relative_x,
123
relative_y: relative_y,
124
in_visualization: in_visualization
125
)}
126
end
127
128
def handle_event("save_point", _params, socket) do
129
if socket.assigns.in_visualization do
130
# Create a new favorite point with the current coordinates and user color
131
new_point = %{
132
x: socket.assigns.relative_x,
133
y: socket.assigns.relative_y,
134
color: socket.assigns.user_color,
135
user_id: socket.assigns.user_id,
136
timestamp: DateTime.utc_now()
137
}
138
139
# Add the new point to the list of favorite points
140
updated_points = [new_point | socket.assigns.favorite_points]
141
142
# Store the point in the ETS table
143
CursorPoints.add_point(new_point)
144
145
# Broadcast the new point to all users
146
broadcast_new_point(new_point)
147
148
{:noreply, assign(socket, favorite_points: updated_points)}
149
else
150
{:noreply, socket}
151
end
152
end
153
154
def handle_event("clear_points", _params, socket) do
155
# Only allow clearing if we have a user_id (connected)
156
if socket.assigns.user_id do
157
# Clear the ETS table
158
CursorPoints.clear_points()
159
160
# Broadcast clear points to all users
161
broadcast_clear_points(socket.assigns.user_id)
162
163
{:noreply, assign(socket, favorite_points: [])}
164
else
165
{:noreply, socket}
166
end
167
end
168
169
def handle_info(
170
{:cursor_position, user_id, color, x, y, relative_x, relative_y, in_viz},
171
socket
172
) do
173
# Skip our own cursor updates
174
if user_id != socket.assigns.user_id do
175
# Update the other user's cursor position
176
other_users =
177
Map.put(socket.assigns.other_users, user_id, %{
178
color: color,
179
x: x,
180
y: y,
181
relative_x: relative_x,
182
relative_y: relative_y,
183
in_viz: in_viz
184
})
185
186
{:noreply, assign(socket, other_users: other_users)}
187
else
188
{:noreply, socket}
189
end
190
end
191
192
def handle_info({:new_point, point}, socket) do
193
# Add the new point to our list
194
updated_points = [point | socket.assigns.favorite_points]
195
196
{:noreply, assign(socket, favorite_points: updated_points)}
197
end
198
199
def handle_info({:clear_points, _user_id}, socket) do
200
{:noreply, assign(socket, favorite_points: [])}
201
end
202
203
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
204
# Update the other users list when presence changes
205
other_users = list_present_users(socket.assigns.user_id)
206
207
{:noreply, assign(socket, other_users: other_users)}
208
end
209
210
def handle_info({:user_joined, user_id, color}, socket) do
211
# Skip our own join messages
212
if user_id != socket.assigns.user_id do
213
# Add the new user to our list of other users
214
other_users =
215
Map.put(socket.assigns.other_users, user_id, %{
216
color: color,
217
x: 0,
218
y: 0,
219
relative_x: 0,
220
relative_y: 0,
221
in_viz: false
222
})
223
224
{:noreply, assign(socket, other_users: other_users)}
225
else
226
{:noreply, socket}
227
end
228
end
229
230
def handle_info(:tick, socket) do
231
# Update the next clear time
232
next_clear = calculate_next_clear()
233
234
# Schedule the next tick
235
if connected?(socket) do
236
Process.send_after(self(), :tick, 1000)
237
end
238
239
{:noreply, assign(socket, next_clear: next_clear)}
240
end
241
242
defp generate_user_id do
243
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
244
end
245
246
defp generate_user_color(user_id) do
247
# Generate a deterministic color based on the user ID
248
<<r, g, b, _rest::binary>> = :crypto.hash(:md5, user_id)
249
250
# Ensure colors are bright enough to see
251
r = min(255, r + 100)
252
g = min(255, g + 100)
253
b = min(255, b + 100)
254
255
"rgb(#{r}, #{g}, #{b})"
256
end
257
258
defp list_present_users(current_user_id) do
259
BlogWeb.Presence.list(@topic)
260
|> Enum.filter(fn {user_id, _} -> user_id != current_user_id end)
261
|> Enum.map(fn {user_id, %{metas: [meta | _]}} ->
262
cursor = Map.get(meta, :cursor, %{x: 0, y: 0, in_viz: false})
263
264
{user_id,
265
%{
266
color: meta.color,
267
x: Map.get(cursor, :x, 0),
268
y: Map.get(cursor, :y, 0),
269
relative_x: Map.get(cursor, :relative_x, 0),
270
relative_y: Map.get(cursor, :relative_y, 0),
271
in_viz: Map.get(cursor, :in_viz, false)
272
}}
273
end)
274
|> Enum.into(%{})
275
end
276
277
defp broadcast_cursor_position(user_id, color, x, y, relative_x, relative_y, in_viz) do
278
Phoenix.PubSub.broadcast(
279
Blog.PubSub,
280
@topic,
281
{:cursor_position, user_id, color, x, y, relative_x, relative_y, in_viz}
282
)
283
end
284
285
defp broadcast_new_point(point) do
286
Phoenix.PubSub.broadcast(
287
Blog.PubSub,
288
@topic,
289
{:new_point, point}
290
)
291
end
292
293
defp broadcast_clear_points(user_id) do
294
Phoenix.PubSub.broadcast(
295
Blog.PubSub,
296
@topic,
297
{:clear_points, user_id}
298
)
299
end
300
301
defp broadcast_join(user_id, color) do
302
Phoenix.PubSub.broadcast(
303
Blog.PubSub,
304
@topic,
305
{:user_joined, user_id, color}
306
)
307
end
308
309
# Update the get_shared_points function to load from ETS
310
defp get_shared_points do
311
CursorPoints.get_points()
312
end
313
314
defp calculate_next_clear do
315
# Calculate time until next scheduled clear
316
# This is a simplification - in a real app you'd want to sync with the actual schedule
317
now = DateTime.utc_now() |> DateTime.to_unix()
318
elapsed = rem(now, @clear_interval)
319
remaining = @clear_interval - elapsed
320
321
# Format the remaining time
322
hours = div(remaining, 3600)
323
minutes = div(rem(remaining, 3600), 60)
324
seconds = rem(remaining, 60)
325
326
%{
327
hours: hours,
328
minutes: minutes,
329
seconds: seconds,
330
total_seconds: remaining
331
}
332
end
333
334
def render(assigns) do
335
~H"""
336
<div
337
class="min-h-screen bg-black text-green-500 font-mono p-4"
338
phx-hook="CursorTracker"
339
id="cursor-tracker"
340
>
341
<div class="max-w-4xl mx-auto">
342
<%= if flash = Phoenix.Flash.get(@flash, :info) do %>
343
<div class="mb-4 border border-green-500 bg-green-900 bg-opacity-30 p-3 text-green-300">
344
{flash}
345
</div>
346
<% end %>
347
348
<div class="mb-8 border border-green-500 p-4">
349
<h1 class="text-3xl mb-2 glitch-text">CURSOR POSITION TRACKER</h1>
350
<div class="text-2xl glitch-text mb-2">
351
<h1>// ACTIVE USERS: {map_size(@other_users) + 1}</h1>
352
</div>
353
<div class="text-2xl glitch-text mb-2">
354
<h1>Click to draw a point</h1>
355
</div>
356
<div class="grid grid-cols-2 gap-4 mb-8">
357
<div class="border border-green-500 p-4">
358
<div class="text-xs mb-1 opacity-70">X-COORDINATE</div>
359
<div class="text-2xl font-bold tracking-wider">{@x_pos}</div>
360
</div>
361
<div class="border border-green-500 p-4">
362
<div class="text-xs mb-1 opacity-70">Y-COORDINATE</div>
363
<div class="text-2xl font-bold tracking-wider">{@y_pos}</div>
364
</div>
365
</div>
366
367
<div class="border-t border-green-500 pt-4">
368
<div class="flex flex-wrap gap-2">
369
<div class="flex items-center">
370
<div
371
class="w-4 h-4 rounded-full mr-1"
372
style={"background-color: #{@user_color || "rgb(100, 255, 100)"}"}
373
>
374
</div>
375
<span class="text-xs">YOU</span>
376
</div>
377
378
<%= for {user_id, user} <- @other_users do %>
379
<div class="flex items-center">
380
<div class="w-4 h-4 rounded-full mr-1" style={"background-color: #{user.color}"}>
381
</div>
382
<span class="text-xs">{String.slice(user_id, 0, 6)}</span>
383
</div>
384
<% end %>
385
</div>
386
</div>
387
</div>
388
389
<div class="border border-green-500 p-4 mb-8">
390
<div class="flex justify-between items-center mb-2">
391
<div class="text-xs opacity-70">// CURSOR VISUALIZATION</div>
392
<div class="flex items-center gap-4">
393
<div class="text-xs opacity-70">
394
AUTO-CLEAR IN:
395
<span class="font-mono">
396
{String.pad_leading("#{@next_clear.hours}", 2, "0")}:{String.pad_leading(
397
"#{@next_clear.minutes}",
398
2,
399
"0"
400
)}:{String.pad_leading("#{@next_clear.seconds}", 2, "0")}
401
</span>
402
</div>
403
<button
404
phx-click="clear_points"
405
class="text-xs border border-green-500 px-2 py-1 hover:bg-green-900 transition-colors"
406
>
407
CLEAR POINTS
408
</button>
409
</div>
410
</div>
411
412
<div
413
class="relative h-64 border border-green-500 overflow-hidden cursor-crosshair"
414
phx-click="save_point"
415
>
416
<%= if @in_visualization do %>
417
<div
418
class="absolute w-4 h-4 opacity-70"
419
style={"left: calc(#{@relative_x}px - 8px); top: calc(#{@relative_y}px - 8px);"}
420
>
421
<div class="w-full h-full border border-green-500 animate-pulse"></div>
422
</div>
423
<div
424
class="absolute w-1 h-full bg-green-500 opacity-20"
425
style={"left: #{@relative_x}px;"}
426
>
427
</div>
428
<div
429
class="absolute w-full h-1 bg-green-500 opacity-20"
430
style={"top: #{@relative_y}px;"}
431
>
432
</div>
433
434
<div
435
class="absolute text-xs opacity-70"
436
style={"left: calc(#{@relative_x}px + 12px); top: calc(#{@relative_y}px - 12px);"}
437
>
438
X: {@relative_x |> trunc()}, Y: {@relative_y |> trunc()}
439
</div>
440
<% else %>
441
<div class="flex items-center justify-center h-full text-sm opacity-50">
442
Move cursor here to visualize position
443
</div>
444
<% end %>
445
446
<%= for {user_id, user} <- @other_users do %>
447
<%= if user.in_viz do %>
448
<div
449
class="absolute w-4 h-4 opacity-50"
450
style={"left: calc(#{user.relative_x}px - 8px); top: calc(#{user.relative_y}px - 8px);"}
451
>
452
<div class="w-full h-full border-2" style={"border-color: #{user.color}"}></div>
453
</div>
454
<div
455
class="absolute text-xs opacity-50"
456
style={"color: #{user.color}; left: calc(#{user.relative_x}px + 12px); top: calc(#{user.relative_y}px - 12px);"}
457
>
458
{String.slice(user_id, 0, 6)}
459
</div>
460
<% end %>
461
<% end %>
462
463
<%= for point <- @favorite_points do %>
464
<div
465
class="absolute w-3 h-3 rounded-full transform -translate-x-1/2 -translate-y-1/2"
466
style={"background-color: #{point.color}; left: #{point.x}px; top: #{point.y}px;"}
467
title={"Point by #{String.slice(point.user_id || "", 0, 6)} at X: #{trunc(point.x)}, Y: #{trunc(point.y)}"}
468
>
469
</div>
470
<% end %>
471
</div>
472
473
<div class="mt-2 text-xs opacity-70 text-center">
474
Click anywhere in the visualization area to save a point
475
</div>
476
</div>
477
478
<div class="border border-green-500 p-4">
479
<div class="text-xs mb-2 opacity-70">// SYSTEM LOG</div>
480
<div class="h-32 overflow-y-auto font-mono text-xs leading-relaxed">
481
<div>> Current position: X:{@x_pos} Y:{@y_pos}</div>
482
<%= if @in_visualization do %>
483
<div>
484
> Cursor in visualization area: X:{@relative_x |> trunc()} Y:{@relative_y |> trunc()}
485
</div>
486
<% end %>
487
<%= if length(@favorite_points) > 0 do %>
488
<div>> Saved points: {length(@favorite_points)}</div>
489
<%= for {point, index} <- Enum.with_index(Enum.take(@favorite_points, 5)) do %>
490
<div>
491
> Point {index + 1}: X:{point.x |> trunc()} Y:{point.y |> trunc()} by {if point.user_id ==
492
@user_id,
493
do: "you",
494
else:
495
String.slice(
496
point.user_id ||
497
"",
498
0,
499
6
500
)}
501
</div>
502
<% end %>
503
<%= if length(@favorite_points) > 5 do %>
504
<div>> ... and {length(@favorite_points) - 5} more points</div>
505
<% end %>
506
<% end %>
507
<%= if map_size(@other_users) > 0 do %>
508
<div>> Other users online: {map_size(@other_users)}</div>
509
<% end %>
510
<div>
511
> Auto-clear scheduled in {@next_clear.hours}h {@next_clear.minutes}m {@next_clear.seconds}s
512
</div>
513
</div>
514
</div>
515
</div>
516
517
<style>
518
.glitch-text {
519
text-shadow:
520
0.05em 0 0 rgba(255, 0, 0, 0.75),
521
-0.025em -0.05em 0 rgba(0, 255, 0, 0.75),
522
0.025em 0.05em 0 rgba(0, 0, 255, 0.75);
523
animation: glitch 500ms infinite;
524
}
525
526
@keyframes glitch {
527
0% {
528
text-shadow:
529
0.05em 0 0 rgba(255, 0, 0, 0.75),
530
-0.025em -0.05em 0 rgba(0, 255, 0, 0.75),
531
0.025em 0.05em 0 rgba(0, 0, 255, 0.75);
532
}
533
14% {
534
text-shadow:
535
0.05em 0 0 rgba(255, 0, 0, 0.75),
536
-0.025em -0.05em 0 rgba(0, 255, 0, 0.75),
537
0.025em 0.05em 0 rgba(0, 0, 255, 0.75);
538
}
539
15% {
540
text-shadow:
541
-0.05em -0.025em 0 rgba(255, 0, 0, 0.75),
542
0.025em 0.025em 0 rgba(0, 255, 0, 0.75),
543
-0.05em -0.05em 0 rgba(0, 0, 255, 0.75);
544
}
545
49% {
546
text-shadow:
547
-0.05em -0.025em 0 rgba(255, 0, 0, 0.75),
548
0.025em 0.025em 0 rgba(0, 255, 0, 0.75),
549
-0.05em -0.05em 0 rgba(0, 0, 255, 0.75);
550
}
551
50% {
552
text-shadow:
553
0.025em 0.05em 0 rgba(255, 0, 0, 0.75),
554
0.05em 0 0 rgba(0, 255, 0, 0.75),
555
0 -0.05em 0 rgba(0, 0, 255, 0.75);
556
}
557
99% {
558
text-shadow:
559
0.025em 0.05em 0 rgba(255, 0, 0, 0.75),
560
0.05em 0 0 rgba(0, 255, 0, 0.75),
561
0 -0.05em 0 rgba(0, 0, 255, 0.75);
562
}
563
100% {
564
text-shadow:
565
-0.025em 0 0 rgba(255, 0, 0, 0.75),
566
-0.025em -0.025em 0 rgba(0, 255, 0, 0.75),
567
-0.025em -0.05em 0 rgba(0, 0, 255, 0.75);
568
}
569
}
570
</style>
571
</div>
572
"""
573
end
574
end
575