561 lines
17 kB
1
tags: tech,hacking,elxiir,phoenix,liveview
2
3
# Some new tech demos
4
5
## I've been hacking on some silly LiveView demos
6
7
### Cursor Tracker
8
[Demo](https://www.bobbby.online/cursor-tracker)
9
10
This is simple. We
11
12
1. track each users cursor position
13
2. keep that state
14
3. allow users to see each others position live on a canvas
15
4. allow users to draw points on that canvas
16
5. allow users to clear that canvas if someone draws something stupid or it gets too full
17
18
This all is done with a minimal amount of JavaScript.
19
20
We can break down the code for this in a few pretty easy pieces, for anyone curious.
21
22
We begin at our `mount/3`:
23
24
```
25
socket = assign(socket,
26
x_pos: 0,
27
y_pos: 0,
28
relative_x: 0,
29
relative_y: 0,
30
in_visualization: false,
31
favorite_points: [],
32
other_users: %{},
33
user_id: nil,
34
user_color: nil,
35
next_clear: calculate_next_clear(),
36
# snip away meta tag attrs and page config
37
)
38
```
39
40
This is our most minimal state: we get a default position (and relative position for use later to coordinate movement in our bounding box), and have no points saved or other users.
41
We also set the timer for things to be able to be cleared.
42
43
Next we handle if the user is connected or not and get our baseline state set up:
44
45
```
46
if connected?(socket) do
47
# Generate a unique user ID and color
48
user_id = generate_user_id()
49
user_color = generate_user_color(user_id)
50
51
# Subscribe to the PubSub topic
52
Phoenix.PubSub.subscribe(Blog.PubSub, @topic)
53
54
# Subscribe to presence diff events
55
Phoenix.PubSub.subscribe(Blog.PubSub, "presence:" <> @topic)
56
57
# Track presence
58
{:ok, _} = BlogWeb.Presence.track(
59
self(),
60
@topic,
61
user_id,
62
%{
63
color: user_color,
64
joined_at: DateTime.utc_now(),
65
cursor: %{x: 0, y: 0, in_viz: false}
66
}
67
)
68
69
# Get current users
70
other_users = list_present_users(user_id)
71
72
# Broadcast that we've joined
73
broadcast_join(user_id, user_color)
74
75
# Load shared points
76
shared_points = get_shared_points()
77
78
Process.send_after(self(), :tick, 1000)
79
80
{:ok, assign(socket,
81
user_id: user_id,
82
user_color: user_color,
83
other_users: other_users,
84
favorite_points: shared_points
85
)}
86
```
87
88
The comments here pretty much explain it all.
89
90
We are setting a color for the user, getting a cursor set, and assuming they arent inside the visualization that tracks the mouse.
91
92
We also figure out how many other users are here, and load our shared points.
93
This detail can be hand-waved away right now, but its loading from ETS if there have been any points saved.
94
We will cover this momentarily.
95
96
Next, we are going to look at the hook we implement on the JavaScript side to track the mouse as a whole.
97
98
Since the entire page centers around this hook from here, we should cover it first thing.
99
100
```
101
const CursorTracker = {
102
mounted() {
103
this.handleMouseMove = (e) => {
104
// Get the mouse position relative to the viewport
105
const x = e.clientX;
106
const y = e.clientY;
107
108
// Get the visualization container
109
const visualizationContainer = this.el.querySelector('.relative.h-64.border');
110
111
if (visualizationContainer) {
112
// Get the bounding rectangle of the visualization container
113
const rect = visualizationContainer.getBoundingClientRect();
114
115
// Calculate the position relative to the visualization container
116
const relativeX = x - rect.left;
117
const relativeY = y - rect.top;
118
119
// Only send the event if the cursor is within the visualization area
120
// or send viewport coordinates for the main display and relative coordinates for visualization
121
this.pushEvent("mousemove", {
122
x: x,
123
y: y,
124
relativeX: relativeX,
125
relativeY: relativeY,
126
inVisualization: relativeX >= 0 && relativeX <= rect.width &&
127
relativeY >= 0 && relativeY <= rect.height
128
});
129
} else {
130
// Fallback if visualization container is not found
131
this.pushEvent("mousemove", { x: x, y: y });
132
}
133
};
134
135
// Add the event listener to the document
136
document.addEventListener("mousemove", this.handleMouseMove);
137
},
138
139
destroyed() {
140
// Remove the event listener when the element is removed
141
document.removeEventListener("mousemove", this.handleMouseMove);
142
}
143
};
144
145
export default CursorTracker;
146
```
147
148
As we can see, the comments mostly explain this as well.
149
150
We are handling mouse movement by beginning with getting our position.
151
152
Next, we get the visualization container and calculate the position of the mouse relative to it.
153
154
Now, if the cursor is within hte viewing area, we send the event if the cursor is within it to allow user users to be able to track this.
155
156
If its not in the container, we just keep tracking the state.
157
158
With these pieces, we can now broadcast this hook as an event to handle it on the backend:
159
160
```
161
def handle_event("mousemove", %{"x" => x, "y" => y} = params, socket) do
162
# Extract relative coordinates if available
163
relative_x = Map.get(params, "relativeX", 0)
164
relative_y = Map.get(params, "relativeY", 0)
165
in_visualization = Map.get(params, "inVisualization", false)
166
167
# Only broadcast if we have a user_id (connected)
168
if socket.assigns.user_id do
169
# Update presence with new cursor position
170
BlogWeb.Presence.update(
171
self(),
172
@topic,
173
socket.assigns.user_id,
174
fn existing_meta ->
175
Map.put(existing_meta, :cursor, %{
176
x: x,
177
y: y,
178
relative_x: relative_x,
179
relative_y: relative_y,
180
in_viz: in_visualization
181
})
182
end
183
)
184
185
# Broadcast cursor position to all users
186
broadcast_cursor_position(
187
socket.assigns.user_id,
188
socket.assigns.user_color,
189
x,
190
y,
191
relative_x,
192
relative_y,
193
in_visualization
194
)
195
end
196
197
{:noreply, assign(socket,
198
x_pos: x,
199
y_pos: y,
200
relative_x: relative_x,
201
relative_y: relative_y,
202
in_visualization: in_visualization
203
)}
204
end
205
```
206
207
Where to broadcast our cursor position we:
208
209
```
210
defp broadcast_cursor_position(user_id, color, x, y, relative_x, relative_y, in_viz) do
211
Phoenix.PubSub.broadcast(
212
Blog.PubSub,
213
@topic,
214
{:cursor_position, user_id, color, x, y, relative_x, relative_y, in_viz}
215
)
216
end
217
```
218
219
So in this case we push out the pubsub with our new coordinates and in viz status, allowing this all to be drawn if we're in those bounds.
220
221
Our `broadcast_join` function is quite similar:
222
223
```
224
defp broadcast_join(user_id, color) do
225
Phoenix.PubSub.broadcast(
226
Blog.PubSub,
227
@topic,
228
{:user_joined, user_id, color}
229
)
230
end
231
```
232
233
Which in turn sends a `user_joined` message that we handle like this, to get them set up at first:
234
235
```
236
def handle_info({:user_joined, user_id, color}, socket) do
237
# Skip our own join messages
238
if user_id != socket.assigns.user_id do
239
# Add the new user to our list of other users
240
other_users = Map.put(socket.assigns.other_users, user_id, %{
241
color: color,
242
x: 0,
243
y: 0,
244
relative_x: 0,
245
relative_y: 0,
246
in_viz: false
247
})
248
249
{:noreply, assign(socket, other_users: other_users)}
250
else
251
{:noreply, socket}
252
end
253
end
254
```
255
256
And now we're tracking the other users that are around as well.
257
258
We can take a look at our view piece by piece to get an idea of how this all translates to a page now:
259
260
```
261
<h1 class="text-3xl mb-2 glitch-text">CURSOR POSITION TRACKER</h1>
262
<div class="text-2xl glitch-text mb-2"><h1>// ACTIVE USERS: <%= map_size(@other_users) + 1 %></h1></div>
263
<div class="text-2xl glitch-text mb-2"><h1>Click to draw a point</h1></div>
264
<div class="grid grid-cols-2 gap-4 mb-8">
265
<div class="border border-green-500 p-4">
266
<div class="text-xs mb-1 opacity-70">X-COORDINATE</div>
267
<div class="text-2xl font-bold tracking-wider"><%= @x_pos %></div>
268
</div>
269
<div class="border border-green-500 p-4">
270
<div class="text-xs mb-1 opacity-70">Y-COORDINATE</div>
271
<div class="text-2xl font-bold tracking-wider"><%= @y_pos %></div>
272
</div>
273
</div>
274
275
276
```
277
278
This works right off the user's state normally.
279
280
We have a count of users that looks at the other users who we are tracking around as a count + us.
281
282
It let's you know you can click to draw a point, and lists your x and y coordinates.
283
284
This all has been set in pretty straightforward ways
285
286
## Saving Points and Persistence
287
288
Now let's look at how we save points when a user clicks in the visualization area:
289
290
```elixir
291
def handle_event("save_point", _params, socket) do
292
if socket.assigns.in_visualization do
293
# Create a new favorite point with the current coordinates and user color
294
new_point = %{
295
x: socket.assigns.relative_x,
296
y: socket.assigns.relative_y,
297
color: socket.assigns.user_color,
298
user_id: socket.assigns.user_id,
299
timestamp: DateTime.utc_now()
300
}
301
302
# Add the new point to the list of favorite points
303
updated_points = [new_point | socket.assigns.favorite_points]
304
305
# Store the point in the ETS table
306
CursorPoints.add_point(new_point)
307
308
# Broadcast the new point to all users
309
broadcast_new_point(new_point)
310
311
{:noreply, assign(socket, favorite_points: updated_points)}
312
else
313
{:noreply, socket}
314
end
315
end
316
```
317
318
When a user clicks in the visualization area, we:
319
1. Create a new point with the current coordinates, user color, and user ID
320
2. Add it to our local list of favorite points
321
3. Store it in an ETS table for persistence
322
4. Broadcast the new point to all connected users
323
324
The `CursorPoints` module handles the persistence using Erlang Term Storage (ETS):
325
326
```elixir
327
defmodule Blog.CursorPoints do
328
use GenServer
329
require Logger
330
331
@table_name :cursor_favorite_points
332
@max_points 1000 # Limit the number of points to prevent unbounded growth
333
@clear_interval 60 * 60 * 1000 # 60 minutes in milliseconds
334
335
# Client API functions...
336
337
@impl true
338
def init(_) do
339
# Create ETS table
340
table = :ets.new(@table_name, [:named_table, :set, :public])
341
342
# Schedule periodic clearing
343
schedule_clear()
344
345
{:ok, %{table: table}}
346
end
347
348
@impl true
349
def handle_cast({:add_point, point}, state) do
350
# Generate a unique key for the point
351
key = "#{point.user_id}-#{:os.system_time(:millisecond)}"
352
353
# Add the point to the ETS table
354
:ets.insert(@table_name, {key, point})
355
356
# Trim the table if it gets too large
357
trim_table()
358
359
{:noreply, state}
360
end
361
362
# More implementation...
363
end
364
```
365
366
This module:
367
1. Creates and manages an ETS table to store points
368
2. Provides functions to add, retrieve, and clear points
369
3. Automatically trims the table if it gets too large
370
4. Schedules automatic clearing every 60 minutes
371
372
## Handling Other Users' Cursors
373
374
When another user moves their cursor, we receive a message via PubSub:
375
376
```elixir
377
def handle_info({:cursor_position, user_id, color, x, y, relative_x, relative_y, in_viz}, socket) do
378
# Skip our own cursor updates
379
if user_id != socket.assigns.user_id do
380
# Update the other user's cursor position
381
other_users = Map.put(socket.assigns.other_users, user_id, %{
382
color: color,
383
x: x,
384
y: y,
385
relative_x: relative_x,
386
relative_y: relative_y,
387
in_viz: in_viz
388
})
389
390
{:noreply, assign(socket, other_users: other_users)}
391
else
392
{:noreply, socket}
393
end
394
end
395
```
396
397
This updates our local state with the other user's cursor position, which we then render in the UI.
398
399
## Automatic Clearing and Countdown Timer
400
401
To keep the canvas from getting too cluttered, we implemented an automatic clearing mechanism:
402
403
```elixir
404
def handle_info(:tick, socket) do
405
# Update the next clear time
406
next_clear = calculate_next_clear()
407
408
# Schedule the next tick
409
if connected?(socket) do
410
Process.send_after(self(), :tick, 1000)
411
end
412
413
{:noreply, assign(socket, next_clear: next_clear)}
414
end
415
416
defp calculate_next_clear do
417
# Calculate time until next scheduled clear
418
now = DateTime.utc_now() |> DateTime.to_unix()
419
elapsed = rem(now, @clear_interval)
420
remaining = @clear_interval - elapsed
421
422
# Format the remaining time
423
hours = div(remaining, 3600)
424
minutes = div(rem(remaining, 3600), 60)
425
seconds = rem(remaining, 60)
426
427
%{
428
hours: hours,
429
minutes: minutes,
430
seconds: seconds,
431
total_seconds: remaining
432
}
433
end
434
```
435
436
This creates a countdown timer that shows users when the next automatic clearing will happen. The actual clearing is handled by the `CursorPoints` GenServer:
437
438
```elixir
439
@impl true
440
def handle_info(:scheduled_clear, state) do
441
# Clear all points from the ETS table
442
:ets.delete_all_objects(@table_name)
443
444
# Broadcast that points were cleared
445
broadcast_clear("SYSTEM")
446
447
# Reschedule the next clearing
448
schedule_clear()
449
450
{:noreply, state}
451
end
452
453
defp schedule_clear do
454
Process.send_after(self(), :scheduled_clear, @clear_interval)
455
end
456
```
457
458
## Presence for User Tracking
459
460
We use Phoenix Presence to track connected users:
461
462
```elixir
463
defp list_present_users(current_user_id) do
464
BlogWeb.Presence.list(@topic)
465
|> Enum.reject(fn {user_id, _} -> user_id == current_user_id end)
466
|> Enum.map(fn {user_id, %{metas: [meta | _]}} ->
467
{user_id, %{
468
color: meta.color,
469
x: get_in(meta, [:cursor, :x]) || 0,
470
y: get_in(meta, [:cursor, :y]) || 0,
471
relative_x: get_in(meta, [:cursor, :relative_x]) || 0,
472
relative_y: get_in(meta, [:cursor, :relative_y]) || 0,
473
in_viz: get_in(meta, [:cursor, :in_viz]) || false
474
}}
475
end)
476
|> Enum.into(%{})
477
end
478
```
479
480
This function:
481
1. Gets the list of present users from Phoenix Presence
482
2. Filters out the current user
483
3. Extracts the relevant information for each user
484
4. Converts the list to a map for easy access
485
486
We also handle presence diff events to update our list of users when someone joins or leaves:
487
488
```elixir
489
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
490
# Update the other users list when presence changes
491
other_users = list_present_users(socket.assigns.user_id)
492
493
{:noreply, assign(socket, other_users: other_users)}
494
end
495
```
496
497
## Rendering the UI
498
499
The UI is rendered using HEEx templates with a retro hacker aesthetic:
500
501
```elixir
502
<div class="min-h-screen bg-black text-green-500 font-mono p-4" phx-hook="CursorTracker" id="cursor-tracker">
503
<!-- Header and user info -->
504
505
<!-- Visualization area -->
506
<div
507
class="relative h-64 border border-green-500 overflow-hidden cursor-crosshair"
508
phx-click="save_point"
509
>
510
<!-- Current user's cursor -->
511
<%= if @in_visualization do %>
512
<!-- Cursor visualization -->
513
<% else %>
514
<!-- Prompt to move cursor into visualization area -->
515
<% end %>
516
517
<!-- Other users' cursors -->
518
<%= for {user_id, user} <- @other_users do %>
519
<%= if user.in_viz do %>
520
<!-- Other user cursor visualization -->
521
<% end %>
522
<% end %>
523
524
<!-- Saved points -->
525
<%= for point <- @favorite_points do %>
526
<!-- Point visualization -->
527
<% end %>
528
</div>
529
530
<!-- System log and other UI elements -->
531
</div>
532
```
533
534
The UI includes:
535
1. A header showing the current cursor position
536
2. A visualization area where users can see cursors and points
537
3. A list of connected users
538
4. A countdown timer to the next automatic clearing
539
5. A button to manually clear all points
540
6. A system log showing recent activity
541
542
The point drawing system works through several coordinated pieces:
543
544
1. When a user clicks in the visualization area, the `phx-click="save_point"` event handler captures the click coordinates relative to the container.
545
546
2. The LiveView handles this event by adding a new point to the `@favorite_points` list with:
547
- The x/y coordinates from the click
548
- The user's unique color
549
- Their user ID
550
- A timestamp
551
552
3. The points are rendered as absolutely positioned divs within the visualization container:
553
554
```
555
<div
556
class="absolute w-3 h-3 rounded-full transform -translate-x-1/2 -translate-y-1/2"
557
style={"background-color: #{point.color}; left: #{point.x}px; top: #{point.y}px;"}
558
title={"Point by #{String.slice(point.user_id || "", 0, 6)} at X: #{trunc(point.x)}, Y: #{trunc(point.y)}"}
559
>
560
</div>
561
```