fuzzyworld

north-star / main_old / files / src / features / participants / panel.ts

Branch main_old · default branch · all branches

panel.ts

12.5 KB · history · blame

1import { PUBLIC_SETTINGS } from '../../public_settings';
2
3type ParticipantModel = {
4 id: string;
5 name: string;
6 color: string;
7 position: { lat: number; lon: number };
8 timestamp?: number;
9 lastSeen?: number;
10};
11
12export const createParticipantsPanelController = ({
13 participantsPanel,
14 participantsToggle,
15 requestRender,
16 getReadableTextColor,
17 northMarkerId,
18 selfParticipant,
19 selfPosition
20}: {
21 participantsPanel: HTMLElement | null;
22 participantsToggle: HTMLElement | null;
23 requestRender: () => void;
24 getReadableTextColor: (hexColor: string) => string;
25 northMarkerId: string;
26 selfParticipant: { id: string };
27 selfPosition: { lat: number; lon: number };
28}) => {
29 const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'friend-compass-friend-visibility';
30 const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'friend-compass-friends-panel-visible';
31 const PARTICIPANT_ORDER_STORAGE_KEY = 'friend-compass-friend-order';
32
33 const participantVisibility = (() => {
34 try {
35 const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY);
36 return raw ? JSON.parse(raw) as Record<string, boolean> : {} as Record<string, boolean>;
37 } catch {
38 return {} as Record<string, boolean>;
39 }
40 })();
41
42 let participantOrder: string[] = (() => {
43 try {
44 const raw = localStorage.getItem(PARTICIPANT_ORDER_STORAGE_KEY);
45 const parsed = raw ? JSON.parse(raw) : [];
46 return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
47 } catch {
48 return [];
49 }
50 })();
51
52 const saveParticipantOrder = () => {
53 localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(participantOrder));
54 };
55
56 const getParticipantDefaultRank = (participantId: string) => {
57 if (participantId === northMarkerId) return 0;
58 return 1;
59 };
60
61 const ensureParticipantOrder = (participantsToRender: ParticipantModel[]) => {
62 const ids = participantsToRender.map((participant: ParticipantModel) => participant.id);
63 const idSet = new Set(ids);
64 const preserved = participantOrder.filter((id: string) => idSet.has(id));
65 const preservedSet = new Set(preserved);
66 const missing = ids.filter((id: string) => !preservedSet.has(id));
67
68 missing.sort((a: string, b: string) => {
69 const rankDelta = getParticipantDefaultRank(a) - getParticipantDefaultRank(b);
70 if (rankDelta !== 0) return rankDelta;
71 return a.localeCompare(b);
72 });
73
74 const nextOrder = [...preserved, ...missing];
75 const changed =
76 nextOrder.length !== participantOrder.length ||
77 nextOrder.some((id: string, index: number) => id !== participantOrder[index]);
78
79 if (changed) {
80 participantOrder = nextOrder;
81 saveParticipantOrder();
82 }
83
84 return participantOrder;
85 };
86
87 const sortParticipantsForDisplay = (participantsToRender: ParticipantModel[]) => {
88 const order = ensureParticipantOrder(participantsToRender);
89 const orderIndex = new Map<string, number>(order.map((id: string, index: number) => [id, index]));
90
91 return [...participantsToRender].sort((a, b) => {
92 const aIndex = orderIndex.get(a.id);
93 const bIndex = orderIndex.get(b.id);
94 return (aIndex ?? Number.MAX_SAFE_INTEGER) - (bIndex ?? Number.MAX_SAFE_INTEGER);
95 });
96 };
97
98 let draggedParticipantId: string | null = null;
99 let draggedPointerId: number | null = null;
100 let dropTargetParticipantId: string | null = null;
101 let dropAfterTarget = false;
102 let lastParticipantsToRender: ParticipantModel[] = [];
103
104 const clearParticipantDragVisuals = () => {
105 if (!participantsPanel) return;
106
107 participantsPanel.querySelectorAll<HTMLElement>('.participant-row').forEach((row) => {
108 row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after');
109 });
110 };
111
112 const moveParticipantToIndex = (participantId: string, targetIndex: number) => {
113 const fromIndex = participantOrder.indexOf(participantId);
114 if (fromIndex < 0) return false;
115
116 const boundedIndex = Math.max(0, Math.min(targetIndex, participantOrder.length - 1));
117 if (fromIndex === boundedIndex) return false;
118
119 participantOrder.splice(fromIndex, 1);
120 participantOrder.splice(boundedIndex, 0, participantId);
121 saveParticipantOrder();
122 return true;
123 };
124
125 const endParticipantDrag = () => {
126 draggedParticipantId = null;
127 draggedPointerId = null;
128 dropTargetParticipantId = null;
129 dropAfterTarget = false;
130 clearParticipantDragVisuals();
131 };
132
133 const getInitialParticipantsPanelVisible = () => {
134 const saved = localStorage.getItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY);
135 if (saved === 'true' || saved === 'false') {
136 return saved === 'true';
137 }
138
139 return !window.matchMedia('(max-width: 720px)').matches;
140 };
141
142 let participantsPanelVisible = getInitialParticipantsPanelVisible();
143 const applyParticipantsPanelVisibility = () => {
144 if (participantsPanel) {
145 participantsPanel.classList.toggle('is-hidden', !participantsPanelVisible);
146 }
147 if (participantsToggle) {
148 participantsToggle.textContent = participantsPanelVisible ? 'Hide Friends' : 'Show Friends';
149 participantsToggle.setAttribute('aria-expanded', String(participantsPanelVisible));
150 }
151
152 localStorage.setItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY, String(participantsPanelVisible));
153 };
154
155 if (participantsToggle) {
156 participantsToggle.addEventListener('click', () => {
157 participantsPanelVisible = !participantsPanelVisible;
158 applyParticipantsPanelVisibility();
159 });
160 }
161
162 document.addEventListener('pointerdown', (event) => {
163 if (!participantsPanelVisible) return;
164
165 const target = event.target;
166 if (!(target instanceof Node)) return;
167
168 const clickedInsidePanel = participantsPanel ? participantsPanel.contains(target) : false;
169 const clickedToggle = participantsToggle ? participantsToggle.contains(target) : false;
170
171 if (clickedInsidePanel || clickedToggle) return;
172
173 participantsPanelVisible = false;
174 applyParticipantsPanelVisibility();
175 });
176
177 document.addEventListener('pointermove', (event: PointerEvent) => {
178 if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
179
180 event.preventDefault();
181
182 const pointed = document.elementFromPoint(event.clientX, event.clientY);
183 if (!(pointed instanceof Element) || !participantsPanel) {
184 dropTargetParticipantId = null;
185 dropAfterTarget = false;
186 clearParticipantDragVisuals();
187 return;
188 }
189
190 const targetRow = pointed.closest('.participant-row');
191 clearParticipantDragVisuals();
192
193 const draggedRow = participantsPanel.querySelector(`[data-participant-id="${draggedParticipantId}"]`);
194 if (draggedRow) {
195 draggedRow.classList.add('is-dragging');
196 }
197
198 if (!(targetRow instanceof HTMLElement) || !participantsPanel.contains(targetRow)) {
199 dropTargetParticipantId = null;
200 dropAfterTarget = false;
201 return;
202 }
203
204 const targetId = targetRow.dataset.participantId;
205 if (!targetId || targetId === draggedParticipantId) {
206 dropTargetParticipantId = null;
207 dropAfterTarget = false;
208 return;
209 }
210
211 const rect = targetRow.getBoundingClientRect();
212 const after = event.clientY > rect.top + rect.height / 2;
213
214 dropTargetParticipantId = targetId;
215 dropAfterTarget = after;
216 targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before');
217 });
218
219 document.addEventListener('pointerup', (event: PointerEvent) => {
220 if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
221
222 const draggedId = draggedParticipantId;
223 const targetId = dropTargetParticipantId;
224 const insertAfter = dropAfterTarget;
225 endParticipantDrag();
226
227 if (!targetId) return;
228
229 const targetIndex = participantOrder.indexOf(targetId);
230 if (targetIndex < 0) return;
231
232 let nextIndex = insertAfter ? targetIndex + 1 : targetIndex;
233 const currentIndex = participantOrder.indexOf(draggedId);
234 if (currentIndex >= 0 && currentIndex < nextIndex) {
235 nextIndex -= 1;
236 }
237
238 if (!moveParticipantToIndex(draggedId, nextIndex)) return;
239
240 renderParticipantControls(lastParticipantsToRender);
241 requestRender();
242 });
243
244 document.addEventListener('pointercancel', () => {
245 if (!draggedParticipantId) return;
246 endParticipantDrag();
247 });
248
249 applyParticipantsPanelVisibility();
250
251 const isParticipantVisible = (participantId: string) => participantVisibility[participantId] !== false;
252 const setParticipantVisibility = (participantId: string, visible: boolean) => {
253 participantVisibility[participantId] = visible;
254 localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(participantVisibility));
255 };
256
257 const getParticipantsToRender = (participants: ParticipantModel[]) => {
258 const participantsToRender = participants.filter((p: ParticipantModel) => p.id !== selfParticipant.id);
259
260 if (participantsToRender.findIndex((p: ParticipantModel) => p.id === northMarkerId) === -1) {
261 participantsToRender.push({
262 id: northMarkerId,
263 name: 'North',
264 color: '#000000',
265 position: {
266 lat: selfPosition.lat + 0.001,
267 lon: selfPosition.lon
268 },
269 timestamp: Date.now()
270 });
271 }
272
273 return sortParticipantsForDisplay(participantsToRender);
274 };
275
276 const renderParticipantControls = (participantsToRender: ParticipantModel[]) => {
277 lastParticipantsToRender = participantsToRender;
278 if (!participantsPanel) return;
279
280 participantsPanel.textContent = '';
281 const heading = document.createElement('h3');
282 heading.textContent = 'Participants';
283 participantsPanel.appendChild(heading);
284
285 for (const participant of participantsToRender) {
286 const row = document.createElement('div');
287 row.className = 'participant-row';
288 row.dataset.participantId = participant.id;
289
290 const checkbox = document.createElement('input');
291 checkbox.type = 'checkbox';
292 checkbox.checked = isParticipantVisible(participant.id);
293 checkbox.addEventListener('change', () => {
294 setParticipantVisibility(participant.id, checkbox.checked);
295 requestRender();
296 });
297
298 const nameWrapper = document.createElement('div');
299 nameWrapper.className = 'participant-name-badge';
300 nameWrapper.style.backgroundColor = participant.color;
301 nameWrapper.style.color = getReadableTextColor(participant.color);
302
303 const nameText = document.createElement('span');
304 nameText.className = 'participant-name';
305 nameText.textContent = participant.name;
306 nameWrapper.appendChild(nameText);
307
308 row.appendChild(checkbox);
309
310 const dragHandle = document.createElement('button');
311 dragHandle.type = 'button';
312 dragHandle.className = 'participant-drag-handle';
313 dragHandle.textContent = PUBLIC_SETTINGS.ui.emojis.dragHandle;
314 dragHandle.title = `Drag to reorder ${participant.name}`;
315 dragHandle.setAttribute('aria-label', `Drag to reorder ${participant.name}`);
316 dragHandle.addEventListener('pointerdown', (event) => {
317 if (event.button !== undefined && event.button !== 0) return;
318
319 event.preventDefault();
320 draggedParticipantId = participant.id;
321 draggedPointerId = event.pointerId;
322 dropTargetParticipantId = null;
323 dropAfterTarget = false;
324
325 clearParticipantDragVisuals();
326 row.classList.add('is-dragging');
327 });
328
329 row.appendChild(dragHandle);
330 row.appendChild(nameWrapper);
331 participantsPanel.appendChild(row);
332 }
333 };
334
335 return {
336 isParticipantVisible,
337 getParticipantsToRender,
338 renderParticipantControls
339 };
340};