north-star / files / src / features / friends / renderer.ts
renderer.ts
| 1 | import { getShortestDelta, normalizeAngle, getFriendPresenceState } from '../../core/utils'; |
| 2 | import type { PresenceThresholds } from '../../core/utils'; |
| 3 | |
| 4 | type ArrowElement = HTMLElement & { currentAngle: number | null }; |
| 5 | |
| 6 | type RenderableFriend = { |
| 7 | id: string; |
| 8 | name: string; |
| 9 | color: string; |
| 10 | position: { lat: number; lon: number }; |
| 11 | timestamp?: number; |
| 12 | lastSeen?: number; |
| 13 | }; |
| 14 | |
| 15 | export 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 | }; |