fuzzyworld

north-star / files / src / features / friends / renderer.ts

renderer.ts

3.8 KB · history · blame

1import { getShortestDelta, normalizeAngle, getFriendPresenceState } from '../../core/utils';
2import type { PresenceThresholds } from '../../core/utils';
3
4type ArrowElement = HTMLElement & { currentAngle: number | null };
5
6type RenderableFriend = {
7 id: string;
8 name: string;
9 color: string;
10 position: { lat: number; lon: number };
11 timestamp?: number;
12 lastSeen?: number;
13};
14
15export const renderArrows = ({
16 content,
17 arrow,
18 friendsToRender,
19 selfFriend,
20 selfHeading,
21 isFriendVisible,
22 getArrowDisplayColor,
23 presenceThresholds,
24}: {
25 content: HTMLElement;
26 arrow: HTMLElement;
27 friendsToRender: RenderableFriend[];
28 selfFriend: RenderableFriend;
29 selfHeading: number;
30 isFriendVisible: (friendId: string) => boolean;
31 getArrowDisplayColor: (hexColor: string) => string;
32 presenceThresholds: PresenceThresholds;
33}) => {
34 const now = Date.now();
35 const activeArrowIds = new Set(
36 friendsToRender
37 .filter((target: RenderableFriend) => target.id !== selfFriend.id)
38 .map((target: RenderableFriend) => `arrow-${target.id}`)
39 );
40
41 for (const target of friendsToRender) {
42 if (target.id === selfFriend.id) continue;
43
44 let existingArrow = document.getElementById(`arrow-${target.id}`) as ArrowElement | null;
45 if (!existingArrow) {
46 const newArrow = arrow.cloneNode(true) as ArrowElement;
47 newArrow.id = `arrow-${target.id}`;
48 newArrow.style.display = 'block';
49 newArrow.currentAngle = null;
50
51 const arrowLabelAnchor = document.createElement('div');
52 arrowLabelAnchor.className = 'arrow-label-anchor';
53
54 const arrowLabel = document.createElement('span');
55 arrowLabel.className = 'arrow-label';
56 arrowLabel.textContent = target.name;
57 arrowLabelAnchor.appendChild(arrowLabel);
58 newArrow.appendChild(arrowLabelAnchor);
59
60 content.appendChild(newArrow);
61 existingArrow = newArrow;
62 }
63
64 content.appendChild(existingArrow);
65
66 if (!isFriendVisible(target.id)) {
67 existingArrow.style.display = 'none';
68 continue;
69 }
70
71 existingArrow.style.display = 'block';
72
73 const dx = target.position.lon - selfFriend.position.lon;
74 const dy = target.position.lat - selfFriend.position.lat;
75 const rawAngle = Math.atan2(dx, dy) * (180 / Math.PI);
76 const targetAngle = normalizeAngle(rawAngle - selfHeading);
77
78 let currentArrowAngle = existingArrow.currentAngle;
79 if (currentArrowAngle === null) {
80 currentArrowAngle = targetAngle;
81 } else {
82 currentArrowAngle += getShortestDelta(currentArrowAngle, targetAngle) * 0.1;
83 }
84
85 existingArrow.currentAngle = currentArrowAngle;
86 existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`;
87 existingArrow.style.color = getArrowDisplayColor(target.color);
88 existingArrow.style.translate = '';
89
90 const presenceState = getFriendPresenceState(target, now, presenceThresholds);
91 existingArrow.classList.toggle('is-online', presenceState === 'online');
92 existingArrow.classList.toggle('is-recent-offline', presenceState === 'recent-offline');
93 existingArrow.classList.toggle('is-stale-offline', presenceState === 'stale-offline');
94
95 const arrowLabel = existingArrow.querySelector('.arrow-label') as HTMLElement | null;
96 if (arrowLabel) {
97 arrowLabel.textContent = target.name;
98 arrowLabel.style.transform = `rotate(${-currentArrowAngle}deg)`;
99 }
100 }
101
102 content.querySelectorAll<HTMLElement>('.arrow[id^="arrow-"]').forEach((arrowElement: HTMLElement) => {
103 if (!activeArrowIds.has(arrowElement.id)) {
104 arrowElement.remove();
105 }
106 });
107};