north-star / commit
testing arrow separation
src/renderer.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/renderer.ts b/src/renderer.ts index eaefef8..7e9d7aa 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -3,6 +3,81 @@ import type { PresenceThresholds } from './utils'; type ArrowElement = HTMLElement & { currentAngle: number | null }; +const OVERLAP_CLUSTER_DEG = 8; +const OVERLAP_SPACING_PX = 16; + +const getAngleDistance = (a: number, b: number) => Math.abs(getShortestDelta(a, b)); + +const buildOverlapOffsets = ({ + friendsToRender, + selfFriend, + selfHeading, + isFriendVisible, +}: { + friendsToRender: any[]; + selfFriend: any; + selfHeading: number; + isFriendVisible: (friendId: string) => boolean; +}) => { + const angleEntries = friendsToRender + .filter((target) => target.id !== selfFriend.id && isFriendVisible(target.id)) + .map((target) => { + const dx = target.position.lon - selfFriend.position.lon; + const dy = target.position.lat - selfFriend.position.lat; + const rawAngle = Math.atan2(dx, dy) * (180 / Math.PI); + return { + id: target.id, + angle: normalizeAngle(rawAngle - selfHeading), + }; + }) + .sort((a, b) => a.angle - b.angle); + + if (angleEntries.length === 0) return new Map<string, number>(); + + const clusters: Array<Array<{ id: string; angle: number }>> = []; + for (const entry of angleEntries) { + const currentCluster = clusters[clusters.length - 1]; + if (!currentCluster) { + clusters.push([entry]); + continue; + } + + const lastInCluster = currentCluster[currentCluster.length - 1]; + if (getAngleDistance(lastInCluster.angle, entry.angle) <= OVERLAP_CLUSTER_DEG) { + currentCluster.push(entry); + } else { + clusters.push([entry]); + } + } + + if (clusters.length > 1) { + const firstCluster = clusters[0]; + const lastCluster = clusters[clusters.length - 1]; + const firstAngle = firstCluster[0].angle; + const lastAngle = lastCluster[lastCluster.length - 1].angle; + if (getAngleDistance(firstAngle, lastAngle) <= OVERLAP_CLUSTER_DEG) { + clusters[0] = [...lastCluster, ...firstCluster]; + clusters.pop(); + } + } + + const offsetById = new Map<string, number>(); + for (const cluster of clusters) { + if (cluster.length === 1) { + offsetById.set(cluster[0].id, 0); + continue; + } + + const stableCluster = [...cluster].sort((a, b) => a.id.localeCompare(b.id)); + const midpoint = (stableCluster.length - 1) / 2; + stableCluster.forEach((entry, index) => { + offsetById.set(entry.id, (index - midpoint) * OVERLAP_SPACING_PX); + }); + } + + return offsetById; +}; + export const renderArrows = ({ content, arrow, @@ -14,6 +89,12 @@ export const renderArrows = ({ presenceThresholds, }) => { const now = Date.now(); + const overlapOffsetById = buildOverlapOffsets({ + friendsToRender, + selfFriend, + selfHeading, + isFriendVisible, + }); const activeArrowIds = new Set( friendsToRender .filter((target) => target.id !== selfFriend.id) @@ -68,6 +149,12 @@ export const renderArrows = ({ existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`; existingArrow.style.color = getArrowDisplayColor(target.color); + const overlapOffset = overlapOffsetById.get(target.id) ?? 0; + const angleRad = currentArrowAngle * (Math.PI / 180); + const offsetX = overlapOffset * Math.cos(angleRad); + const offsetY = overlapOffset * Math.sin(angleRad); + existingArrow.style.translate = `calc(-50% + ${offsetX.toFixed(2)}px) calc(-50% + ${offsetY.toFixed(2)}px)`; + const presenceState = getFriendPresenceState(target, now, presenceThresholds); existingArrow.classList.toggle('is-online', presenceState === 'online'); existingArrow.classList.toggle('is-recent-offline', presenceState === 'recent-offline');