north-star / main_old / files / src / features / friends / renderer.ts
Branch main_old · default branch · all branches
renderer.ts
f32f5e95 2026-03-16 Pavel Safronov | 1 | import { getShortestDelta, normalizeAngle, getFriendPresenceState } from '../../core/utils'; |
| 2 | import type { PresenceThresholds } from '../../core/utils'; | |
92f24c4e 2026-03-16 Pavel Safronov | 3 | |
| 4 | type ArrowElement = HTMLElement & { currentAngle: number | null }; | |
36d64b37 2026-03-15 Pavel Safronov | 5 | |
b4e7f682 2026-03-16 Pavel Safronov | 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 | ||
36d64b37 2026-03-15 Pavel Safronov | 15 | export const renderArrows = ({ |
| 16 | content, | |
| 17 | arrow, | |
e790982a 2026-03-16 Pavel Safronov | 18 | friendsToRender, |
| 19 | selfFriend, | |
36d64b37 2026-03-15 Pavel Safronov | 20 | selfHeading, |
e790982a 2026-03-16 Pavel Safronov | 21 | isFriendVisible, |
e4870993 2026-03-16 Pavel Safronov | 22 | getArrowDisplayColor, |
9fda5271 2026-03-16 Pavel Safronov | 23 | presenceThresholds, |
b4e7f682 2026-03-16 Pavel Safronov | 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; | |
36d64b37 2026-03-15 Pavel Safronov | 33 | }) => { |
e4870993 2026-03-16 Pavel Safronov | 34 | const now = Date.now(); |
36d64b37 2026-03-15 Pavel Safronov | 35 | const activeArrowIds = new Set( |
e790982a 2026-03-16 Pavel Safronov | 36 | friendsToRender |
b4e7f682 2026-03-16 Pavel Safronov | 37 | .filter((target: RenderableFriend) => target.id !== selfFriend.id) |
| 38 | .map((target: RenderableFriend) => `arrow-${target.id}`) | |
36d64b37 2026-03-15 Pavel Safronov | 39 | ); |
| 40 | ||
e790982a 2026-03-16 Pavel Safronov | 41 | for (const target of friendsToRender) { |
| 42 | if (target.id === selfFriend.id) continue; | |
36d64b37 2026-03-15 Pavel Safronov | 43 | |
92f24c4e 2026-03-16 Pavel Safronov | 44 | let existingArrow = document.getElementById(`arrow-${target.id}`) as ArrowElement | null; |
36d64b37 2026-03-15 Pavel Safronov | 45 | if (!existingArrow) { |
92f24c4e 2026-03-16 Pavel Safronov | 46 | const newArrow = arrow.cloneNode(true) as ArrowElement; |
36d64b37 2026-03-15 Pavel Safronov | 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 | ||
e790982a 2026-03-16 Pavel Safronov | 66 | if (!isFriendVisible(target.id)) { |
36d64b37 2026-03-15 Pavel Safronov | 67 | existingArrow.style.display = 'none'; |
| 68 | continue; | |
| 69 | } | |
| 70 | ||
| 71 | existingArrow.style.display = 'block'; | |
| 72 | ||
e790982a 2026-03-16 Pavel Safronov | 73 | const dx = target.position.lon - selfFriend.position.lon; |
| 74 | const dy = target.position.lat - selfFriend.position.lat; | |
36d64b37 2026-03-15 Pavel Safronov | 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); | |
efc31a14 2026-03-16 Pavel Safronov | 88 | existingArrow.style.translate = ''; |
feaee583 2026-03-16 Pavel Safronov | 89 | |
e4870993 2026-03-16 Pavel Safronov | 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 | ||
92f24c4e 2026-03-16 Pavel Safronov | 95 | const arrowLabel = existingArrow.querySelector('.arrow-label') as HTMLElement | null; |
36d64b37 2026-03-15 Pavel Safronov | 96 | if (arrowLabel) { |
9fda5271 2026-03-16 Pavel Safronov | 97 | arrowLabel.textContent = target.name; |
36d64b37 2026-03-15 Pavel Safronov | 98 | arrowLabel.style.transform = `rotate(${-currentArrowAngle}deg)`; |
| 99 | } | |
| 100 | } | |
| 101 | ||
b4e7f682 2026-03-16 Pavel Safronov | 102 | content.querySelectorAll<HTMLElement>('.arrow[id^="arrow-"]').forEach((arrowElement: HTMLElement) => { |
36d64b37 2026-03-15 Pavel Safronov | 103 | if (!activeArrowIds.has(arrowElement.id)) { |
| 104 | arrowElement.remove(); | |
| 105 | } | |
| 106 | }); | |
| 107 | }; |