north-star / commit
rename all the things!
database.rules.json | 2 +-
package.json | 2 +-
readme.md | 15 +-
src/app.ts | 64 ++---
src/compass.png | Bin 0 -> 18747 bytes
src/compass.ts | 2 +-
src/darkmode.ts | 2 +-
src/friends-panel.ts | 339 ++++++++++++++++++++++++++
src/{participants-sync.ts => friends-sync.ts} | 40 +--
src/index.html | 26 +-
src/participants-panel.ts | 8 +-
src/renderer.ts | 20 +-
src/self-profile.ts | 26 +-
src/style.css | 53 ++--
tsconfig.json | 17 +-
15 files changed, 490 insertions(+), 126 deletions(-)
diff --git a/database.rules.json b/database.rules.json
index 4230c1a..85c888d 100644
--- a/database.rules.json
+++ b/database.rules.json
@@ -5,7 +5,7 @@
"rooms": {
"$roomId": {
".read": "auth != null && $roomId.matches(/^[a-z0-9]{8}$/)",
- "participants": {
+ "friends": {
"$uid": {
".write": "auth != null && auth.uid === $uid",
".validate": "!newData.exists() || (newData.hasChildren(['encryptedState']) && newData.child('encryptedState').isString() && newData.child('encryptedState').val().length > 0 && !newData.child('id').exists() && !newData.child('name').exists() && !newData.child('color').exists() && !newData.child('position').exists() && !newData.child('timestamp').exists()) || (newData.hasChildren(['id','name','color','position','timestamp']) && newData.child('id').isString() && newData.child('id').val() === auth.uid && newData.child('name').isString() && newData.child('name').val().length > 0 && newData.child('name').val().length <= 32 && newData.child('color').isString() && newData.child('color').val().matches(/^#[0-9a-fA-F]{6}$/) && newData.child('position').hasChildren(['lat','lon']) && newData.child('position/lat').isNumber() && newData.child('position/lon').isNumber() && newData.child('timestamp').isNumber())"
diff --git a/package.json b/package.json
index 7d80f0c..2258e76 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "north-star",
+ "name": "friend-compass",
"version": "0.1.0",
"private": true,
"type": "module",
diff --git a/readme.md b/readme.md
index 38f001c..a45717c 100644
--- a/readme.md
+++ b/readme.md
@@ -1,8 +1,8 @@
-# North Star
+# Friend Compass
## What is it?
-North Star is a web application that allows users to share their location in real-time with others in the same "room". It provides a simple interface to see where everyone is relative to themselves.
+Friend Compass is a web application that allows users to share their location in real-time with others in the same "room". It provides a simple interface to see where everyone is relative to themselves.
## How to use it?
@@ -10,18 +10,18 @@ North Star is a web application that allows users to share their location in rea
2. Share the room QR code or the room link with your friends so they can join the same room.
3. Everyone who joins the room will be able to see each other's relative location on the map.
4. Everyone can set a custom name and color for their marker.
-5. You can hide participants or click on a participant's name to open their location in Google Maps.
+5. You can hide friends or click on a friend's name to open their location in Google Maps.
6. The North participant is a special marker that always points to the north.
7. If North isn't pointing to true north, orient your phone so "up" on the screen is facing north, and then click the "Calibrate North" button. This will set the current orientation as north.
8. A mis-aligned north is only a visual issue and does not affect the actual location sharing functionality.
## How does it work?
-The app uses Firebase Realtime Database to store and sync the location data of all participants in real-time. When a user joins a room, they are added to the database with their location and other information. The app listens for changes in the database and updates the map accordingly.
+The app uses Firebase Realtime Database to store and sync the location data of all friends in real-time. When a user joins a room, they are added to the database with their location and other information. The app listens for changes in the database and updates the map accordingly.
## Is it secure?
-The app does not require any authentication, so anyone with the room information can join and see the location of all participants.
+The app does not require any authentication, so anyone with the room information can join and see the location of all friends.
The room link/QR code contains a unique room id and a unique encryption key. This means that even if someone were to gain access to the database (like myself, or a bad actor), they would not be able to read anyone's location data without the room link.
@@ -29,7 +29,7 @@ It's important to share the room QR code/link only with trusted individuals to e
If your link gets leaked or compromised, you can simply create a new room and share the new QR code/link with your friends.
-Here's a screenshot of the database information that's currently (March 2026) stored for a few test rooms. Note that room and participant ids are random strings, and participant information is encrypted. There is no way for me or anyone without the room link to associate the data in the database with any real-world identities or locations, and that's a hard guarantee!
+Here's a screenshot of the database information that's currently (March 2026) stored for a few test rooms. Note that room and friend ids are random strings, and friend information is encrypted. There is no way for me or anyone without the room link to associate the data in the database with any real-world identities or locations, and that's a hard guarantee!

@@ -76,7 +76,7 @@ Firebase console: [https://console.firebase.google.com/project/north-star-e5dee/
Ideas worth tracking (not implemented yet):
- Optional user-facing low-power tracking mode toggle (battery saver style control).
-- Participant stale-state UX polish (for example: clearer badges, subtle icons, better wording).
+- Friend stale-state UX polish (for example: clearer badges, subtle icons, better wording).
- Optional wake-lock mode for situations where people want to keep the app active longer.
- Optional native-wrapper path (Capacitor/PWA strategy) for more reliable background location updates.
- Export/share diagnostics mode for easier field testing (sensor availability, last sync times, etc.).
@@ -102,6 +102,7 @@ You're looking at it.
This project is licensed under the MIT License.
+
#### Why MIT for this project?
Short version: emotional simplicity and momentum.
diff --git a/src/app.ts b/src/app.ts
index 7342c04..6a516be 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -6,9 +6,9 @@ import { createWakeLockManager } from './wakelock';
import { initFirebaseSession } from './firebase';
import { initSession } from './session';
import { initSelfProfile } from './self-profile';
-import { subscribeToParticipants } from './participants-sync';
+import { subscribeToFriends } from './friends-sync';
import { initCompassController } from './compass';
-import { createParticipantsPanelController } from './participants-panel';
+import { createFriendsPanelController } from './friends-panel';
import { renderArrows } from './renderer';
const debugParagraph = document.getElementById('debug');
@@ -20,8 +20,8 @@ const inviteRoomQrList = document.getElementById('invite-room-qr-list');
const calibrateHeadingLink = document.getElementById('calibrate-heading-link');
const darkModeToggle = document.getElementById('dark-mode-toggle');
const createRoomLink = document.getElementById('create-room-link');
-const participantsPanel = document.getElementById('participants-panel');
-const participantsToggle = document.getElementById('participants-toggle');
+const friendsPanel = document.getElementById('friends-panel');
+const friendsToggle = document.getElementById('friends-toggle');
const content = document.getElementById('content');
const arrow = document.getElementById('default-arrow');
@@ -35,7 +35,7 @@ document.head.appendChild(Object.assign(document.createElement('link'), { rel: '
const RENDER_INTERVAL_MS = 100;
const LOCATION_SYNC_INTERVAL_MS = 3000;
-const HEADING_CORRECTION_STORAGE_KEY = 'north-star-heading-correction-deg';
+const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg';
const NORTH_MARKER_ID = 'north-marker';
const GUARDIAN_ANGEL_ID = 'test';
@@ -79,22 +79,22 @@ const { roomId, roomKey } = await initSession({
GITLAB_PAGES_BASE_URL
});
-const participantRef = ref(db, `rooms/${roomId}/participants/${uid}`);
-onDisconnect(participantRef).remove();
+const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`);
+onDisconnect(friendRef).remove();
-// selfParticipant is assigned below by initSelfProfile; syncSelfParticipant is
+// selfFriend is assigned below by initSelfProfile; syncSelfFriend is
// only ever called after that point (from event handlers / intervals).
-let selfParticipant;
+let selfFriend;
-const syncSelfParticipant = async () => {
- selfParticipant.timestamp = Date.now();
+const syncSelfFriend = async () => {
+ selfFriend.timestamp = Date.now();
const dbRecord = roomKey
- ? { encryptedState: await encryptJson(roomKey, selfParticipant) }
- : selfParticipant;
+ ? { encryptedState: await encryptJson(roomKey, selfFriend) }
+ : selfFriend;
try {
- await set(participantRef, dbRecord);
+ await set(friendRef, dbRecord);
} catch (err) {
- console.error('Error syncing participant in database', err);
+ console.error('Error syncing friend in database', err);
}
};
@@ -103,7 +103,7 @@ const maybeSyncLocation = () => {
const now = Date.now();
if (now - lastLocationSyncAt < LOCATION_SYNC_INTERVAL_MS) return;
lastLocationSyncAt = now;
- void syncSelfParticipant();
+ void syncSelfFriend();
};
let renderFrameRequested = false;
@@ -116,13 +116,13 @@ const requestRender = () => {
});
};
-({ selfParticipant } = await initSelfProfile({
+({ selfFriend } = await initSelfProfile({
nameForm,
selfNameInput,
selfColorInput,
uid,
selfPosition,
- syncSelf: syncSelfParticipant,
+ syncSelf: syncSelfFriend,
requestRender
}));
@@ -133,21 +133,21 @@ const compassController = initCompassController({
requestRender
});
-const participantsController = createParticipantsPanelController({
- participantsPanel,
- participantsToggle,
+const friendsController = createFriendsPanelController({
+ friendsPanel,
+ friendsToggle,
requestRender,
getReadableTextColor,
northMarkerId: NORTH_MARKER_ID,
guardianAngelId: GUARDIAN_ANGEL_ID,
- selfParticipant,
+ selfFriend,
selfPosition
});
navigator.geolocation.watchPosition(async (pos) => {
selfPosition.lat = pos.coords.latitude;
selfPosition.lon = pos.coords.longitude;
- selfParticipant.position = { ...selfPosition };
+ selfFriend.position = { ...selfPosition };
maybeSyncLocation();
requestRender();
}, (err) => {
@@ -162,32 +162,32 @@ document.addEventListener('visibilitychange', () => {
void requestWakeLock();
-const { participants } = subscribeToParticipants({
+const { friends } = subscribeToFriends({
db,
roomId,
roomKey,
onUpdate: (updated) => {
- const participantsToRender = participantsController.getParticipantsToRender(updated);
- participantsController.renderParticipantControls(participantsToRender);
+ const friendsToRender = friendsController.getFriendsToRender(updated);
+ friendsController.renderFriendControls(friendsToRender);
requestRender();
}
});
const render = () => {
- const participantsToRender = participantsController.getParticipantsToRender(participants);
+ const friendsToRender = friendsController.getFriendsToRender(friends);
renderArrows({
content,
arrow,
- participantsToRender,
- selfParticipant,
+ friendsToRender,
+ selfFriend,
selfHeading: compassController.getSelfHeading(),
- isParticipantVisible: participantsController.isParticipantVisible,
+ isFriendVisible: friendsController.isFriendVisible,
getArrowDisplayColor
});
- logDebug(`Real participants: ${participants.length} | Commit: ${isLocalCommit ? 'local' : `<a href="https://gitlab.com/PavelSafronov/north-star/-/commit/${GIT_COMMIT}" target="_blank">${GIT_COMMIT}</a>`} | Repo: <a href="https://gitlab.com/PavelSafronov/north-star" target="_blank">north-star</a>`);
+ logDebug(`Real friends: ${friends.length} | Commit: ${isLocalCommit ? 'local' : `<a href="https://gitlab.com/PavelSafronov/north-star/-/commit/${GIT_COMMIT}" target="_blank">${GIT_COMMIT}</a>`} | Repo: <a href="https://gitlab.com/PavelSafronov/north-star" target="_blank">north-star</a>`);
};
-await syncSelfParticipant();
+await syncSelfFriend();
setInterval(render, RENDER_INTERVAL_MS);
diff --git a/src/compass.png b/src/compass.png
new file mode 100644
index 0000000..632ccd1
Binary files /dev/null and b/src/compass.png differ
diff --git a/src/compass.ts b/src/compass.ts
index 1ab8b06..c2a3603 100644
--- a/src/compass.ts
+++ b/src/compass.ts
@@ -6,7 +6,7 @@ export const initCompassController = ({
onHeadingCorrectionDegChange,
requestRender
}) => {
- const HEADING_CORRECTION_STORAGE_KEY = 'north-star-heading-correction-deg';
+ const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg';
const deviceOrientationCtor =
typeof DeviceOrientationEvent !== 'undefined'
? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<'granted' | 'denied'> })
diff --git a/src/darkmode.ts b/src/darkmode.ts
index be81bf4..a3e4952 100644
--- a/src/darkmode.ts
+++ b/src/darkmode.ts
@@ -1,4 +1,4 @@
-export const DARK_MODE_STORAGE_KEY = 'north-star-dark-mode';
+export const DARK_MODE_STORAGE_KEY = 'friend-compass-dark-mode';
export const initDarkMode = (toggleButton) => {
const applyDarkMode = (dark) => {
diff --git a/src/friends-panel.ts b/src/friends-panel.ts
new file mode 100644
index 0000000..9224d3c
--- /dev/null
+++ b/src/friends-panel.ts
@@ -0,0 +1,339 @@
+export const createFriendsPanelController = ({
+ friendsPanel,
+ friendsToggle,
+ requestRender,
+ getReadableTextColor,
+ northMarkerId,
+ guardianAngelId,
+ selfFriend,
+ selfPosition
+}) => {
+ const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'friend-compass-friend-visibility';
+ const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'friend-compass-friends-panel-visible';
+ const PARTICIPANT_ORDER_STORAGE_KEY = 'friend-compass-friend-order';
+
+ const friendVisibility = (() => {
+ try {
+ const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY);
+ return raw ? JSON.parse(raw) : {};
+ } catch {
+ return {};
+ }
+ })();
+
+ let friendOrder = (() => {
+ try {
+ const raw = localStorage.getItem(PARTICIPANT_ORDER_STORAGE_KEY);
+ const parsed = raw ? JSON.parse(raw) : [];
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
+ } catch {
+ return [];
+ }
+ })();
+
+ const saveFriendOrder = () => {
+ localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(friendOrder));
+ };
+
+ const getFriendDefaultRank = (friendId) => {
+ if (friendId === northMarkerId) return 0;
+ if (friendId === guardianAngelId || friendId === 'guardian-angel') return 1;
+ return 2;
+ };
+
+ const shouldShowMapLink = (friendId) => {
+ return friendId !== northMarkerId && friendId !== guardianAngelId;
+ };
+
+ const ensureFriendOrder = (friendsToRender) => {
+ const ids = friendsToRender.map((friend) => friend.id);
+ const idSet = new Set(ids);
+ const preserved = friendOrder.filter((id) => idSet.has(id));
+ const preservedSet = new Set(preserved);
+ const missing = ids.filter((id) => !preservedSet.has(id));
+
+ missing.sort((a, b) => {
+ const rankDelta = getFriendDefaultRank(a) - getFriendDefaultRank(b);
+ if (rankDelta !== 0) return rankDelta;
+ return a.localeCompare(b);
+ });
+
+ const nextOrder = [...preserved, ...missing];
+ const changed =
+ nextOrder.length !== friendOrder.length ||
+ nextOrder.some((id, index) => id !== friendOrder[index]);
+
+ if (changed) {
+ friendOrder = nextOrder;
+ saveFriendOrder();
+ }
+
+ return friendOrder;
+ };
+
+ const sortFriendsForDisplay = (friendsToRender) => {
+ const order = ensureFriendOrder(friendsToRender);
+ const orderIndex = new Map(order.map((id, index) => [id, index]));
+
+ return [...friendsToRender].sort((a, b) => {
+ const aIndex = orderIndex.get(a.id);
+ const bIndex = orderIndex.get(b.id);
+ return aIndex - bIndex;
+ });
+ };
+
+ let draggedFriendId = null;
+ let draggedPointerId = null;
+ let dropTargetFriendId = null;
+ let dropAfterTarget = false;
+ let lastFriendsToRender = [];
+
+ const clearFriendDragVisuals = () => {
+ if (!friendsPanel) return;
+
+ friendsPanel.querySelectorAll('.friend-row').forEach((row) => {
+ row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after');
+ });
+ };
+
+ const moveFriendToIndex = (friendId, targetIndex) => {
+ const fromIndex = friendOrder.indexOf(friendId);
+ if (fromIndex < 0) return false;
+
+ const boundedIndex = Math.max(0, Math.min(targetIndex, friendOrder.length - 1));
+ if (fromIndex === boundedIndex) return false;
+
+ friendOrder.splice(fromIndex, 1);
+ friendOrder.splice(boundedIndex, 0, friendId);
+ saveFriendOrder();
+ return true;
+ };
+
+ const endFriendDrag = () => {
+ draggedFriendId = null;
+ draggedPointerId = null;
+ dropTargetFriendId = null;
+ dropAfterTarget = false;
+ clearFriendDragVisuals();
+ };
+
+ const getInitialFriendsPanelVisible = () => {
+ const saved = localStorage.getItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY);
+ if (saved === 'true' || saved === 'false') {
+ return saved === 'true';
+ }
+
+ return !window.matchMedia('(max-width: 720px)').matches;
+ };
+
+ let friendsPanelVisible = getInitialFriendsPanelVisible();
+ const applyFriendsPanelVisibility = () => {
+ if (friendsPanel) {
+ friendsPanel.classList.toggle('is-hidden', !friendsPanelVisible);
+ }
+ if (friendsToggle) {
+ friendsToggle.textContent = friendsPanelVisible ? 'Hide Friends' : 'Show Friends';
+ friendsToggle.setAttribute('aria-expanded', String(friendsPanelVisible));
+ }
+
+ localStorage.setItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY, String(friendsPanelVisible));
+ };
+
+ if (friendsToggle) {
+ friendsToggle.addEventListener('click', () => {
+ friendsPanelVisible = !friendsPanelVisible;
+ applyFriendsPanelVisibility();
+ });
+ }
+
+ document.addEventListener('pointerdown', (event) => {
+ if (!friendsPanelVisible) return;
+
+ const target = event.target;
+ if (!(target instanceof Node)) return;
+
+ const clickedInsidePanel = friendsPanel ? friendsPanel.contains(target) : false;
+ const clickedToggle = friendsToggle ? friendsToggle.contains(target) : false;
+
+ if (clickedInsidePanel || clickedToggle) return;
+
+ friendsPanelVisible = false;
+ applyFriendsPanelVisibility();
+ });
+
+ document.addEventListener('pointermove', (event) => {
+ if (!draggedFriendId || event.pointerId !== draggedPointerId) return;
+
+ event.preventDefault();
+
+ const pointed = document.elementFromPoint(event.clientX, event.clientY);
+ if (!(pointed instanceof Element) || !friendsPanel) {
+ dropTargetFriendId = null;
+ dropAfterTarget = false;
+ clearFriendDragVisuals();
+ return;
+ }
+
+ const targetRow = pointed.closest('.friend-row');
+ clearFriendDragVisuals();
+
+ const draggedRow = friendsPanel.querySelector(`[data-friend-id="${draggedFriendId}"]`);
+ if (draggedRow) {
+ draggedRow.classList.add('is-dragging');
+ }
+
+ if (!(targetRow instanceof HTMLElement) || !friendsPanel.contains(targetRow)) {
+ dropTargetFriendId = null;
+ dropAfterTarget = false;
+ return;
+ }
+
+ const targetId = targetRow.dataset.friendId;
+ if (!targetId || targetId === draggedFriendId) {
+ dropTargetFriendId = null;
+ dropAfterTarget = false;
+ return;
+ }
+
+ const rect = targetRow.getBoundingClientRect();
+ const after = event.clientY > rect.top + rect.height / 2;
+
+ dropTargetFriendId = targetId;
+ dropAfterTarget = after;
+ targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before');
+ });
+
+ document.addEventListener('pointerup', (event) => {
+ if (!draggedFriendId || event.pointerId !== draggedPointerId) return;
+
+ const draggedId = draggedFriendId;
+ const targetId = dropTargetFriendId;
+ const insertAfter = dropAfterTarget;
+ endFriendDrag();
+
+ if (!targetId) return;
+
+ const targetIndex = friendOrder.indexOf(targetId);
+ if (targetIndex < 0) return;
+
+ let nextIndex = insertAfter ? targetIndex + 1 : targetIndex;
+ const currentIndex = friendOrder.indexOf(draggedId);
+ if (currentIndex >= 0 && currentIndex < nextIndex) {
+ nextIndex -= 1;
+ }
+
+ if (!moveFriendToIndex(draggedId, nextIndex)) return;
+
+ renderFriendControls(lastFriendsToRender);
+ requestRender();
+ });
+
+ document.addEventListener('pointercancel', () => {
+ if (!draggedFriendId) return;
+ endFriendDrag();
+ });
+
+ applyFriendsPanelVisibility();
+
+ const isFriendVisible = (friendId) => friendVisibility[friendId] !== false;
+ const setFriendVisibility = (friendId, visible) => {
+ friendVisibility[friendId] = visible;
+ localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(friendVisibility));
+ };
+
+ const getFriendsToRender = (friends) => {
+ const friendsToRender = friends.filter((p) => p.id !== selfFriend.id);
+
+ if (friendsToRender.findIndex((p) => p.id === northMarkerId) === -1) {
+ friendsToRender.push({
+ id: northMarkerId,
+ name: 'North',
+ color: '#000000',
+ position: {
+ lat: selfPosition.lat + 0.001,
+ lon: selfPosition.lon
+ },
+ timestamp: Date.now()
+ });
+ }
+
+ return sortFriendsForDisplay(friendsToRender);
+ };
+
+ const renderFriendControls = (friendsToRender) => {
+ lastFriendsToRender = friendsToRender;
+ if (!friendsPanel) return;
+
+ friendsPanel.textContent = '';
+ const heading = document.createElement('h3');
+ heading.textContent = 'Friends';
+ friendsPanel.appendChild(heading);
+
+ for (const friend of friendsToRender) {
+ const row = document.createElement('div');
+ row.className = 'friend-row';
+ row.dataset.friendId = friend.id;
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.checked = isFriendVisible(friend.id);
+ checkbox.addEventListener('change', () => {
+ setFriendVisibility(friend.id, checkbox.checked);
+ requestRender();
+ });
+
+ const nameWrapper = document.createElement('div');
+ nameWrapper.className = 'friend-name-badge';
+ nameWrapper.style.backgroundColor = friend.color;
+ nameWrapper.style.color = getReadableTextColor(friend.color);
+
+ const nameText = document.createElement('span');
+ nameText.className = 'friend-name';
+ nameText.textContent = friend.name;
+ nameWrapper.appendChild(nameText);
+
+ const mapLink = document.createElement('a');
+ mapLink.className = 'friend-map-link';
+ mapLink.href = `https://www.google.com/maps?q=${friend.position.lat},${friend.position.lon}`;
+ mapLink.target = '_blank';
+ mapLink.rel = 'noopener noreferrer';
+ mapLink.textContent = '📍';
+ mapLink.title = `Open ${friend.name} in Google Maps`;
+ mapLink.setAttribute('aria-label', `Open ${friend.name} in Google Maps`);
+
+ row.appendChild(checkbox);
+ if (shouldShowMapLink(friend.id)) {
+ nameWrapper.appendChild(mapLink);
+ }
+
+ const dragHandle = document.createElement('button');
+ dragHandle.type = 'button';
+ dragHandle.className = 'friend-drag-handle';
+ dragHandle.textContent = '☰';
+ dragHandle.title = `Drag to reorder ${friend.name}`;
+ dragHandle.setAttribute('aria-label', `Drag to reorder ${friend.name}`);
+ dragHandle.addEventListener('pointerdown', (event) => {
+ if (event.button !== undefined && event.button !== 0) return;
+
+ event.preventDefault();
+ draggedFriendId = friend.id;
+ draggedPointerId = event.pointerId;
+ dropTargetFriendId = null;
+ dropAfterTarget = false;
+
+ clearFriendDragVisuals();
+ row.classList.add('is-dragging');
+ });
+
+ row.appendChild(dragHandle);
+ row.appendChild(nameWrapper);
+ friendsPanel.appendChild(row);
+ }
+ };
+
+ return {
+ isFriendVisible,
+ getFriendsToRender,
+ renderFriendControls
+ };
+};
diff --git a/src/participants-sync.ts b/src/friends-sync.ts
similarity index 54%
rename from src/participants-sync.ts
rename to src/friends-sync.ts
index 6dfb742..c8fb126 100644
--- a/src/participants-sync.ts
+++ b/src/friends-sync.ts
@@ -1,7 +1,7 @@
import { ref, onValue } from 'firebase/database';
import { decryptJson } from './crypto';
-type ParticipantRecord = {
+type FriendRecord = {
id?: string;
encryptedState?: string;
encryptedPosition?: string;
@@ -9,33 +9,33 @@ type ParticipantRecord = {
};
/**
- * Subscribes to the participants node for a room, decrypting each record
- * as needed. Calls onUpdate(participants) after every snapshot resolves.
+ * Subscribes to the friends node for a room, decrypting each record
+ * as needed. Calls onUpdate(friends) after every snapshot resolves.
*
* @param {object} opts
* @param {object} opts.db - Firebase Database instance
* @param {string} opts.roomId
* @param {CryptoKey|null} opts.roomKey - AES-GCM key, or null for unencrypted rooms
- * @param {function} opts.onUpdate - (participants: object[]) => void
- * @returns {{ participants: object[] }} Shared array reference, mutated in place on each update
+ * @param {function} opts.onUpdate - (friends: object[]) => void
+ * @returns {{ friends: object[] }} Shared array reference, mutated in place on each update
*/
-export function subscribeToParticipants({ db, roomId, roomKey, onUpdate }) {
- const participantsRef = ref(db, `rooms/${roomId}/participants`);
- const participants = [];
+export function subscribeToFriends({ db, roomId, roomKey, onUpdate }) {
+ const friendsRef = ref(db, `rooms/${roomId}/friends`);
+ const friends = [];
let snapshotVersion = 0;
- onValue(participantsRef, async (snapshot) => {
+ onValue(friendsRef, async (snapshot) => {
const version = ++snapshotVersion;
- const participantsData = snapshot.val();
- if (participantsData) {
- const rawParticipants = Object.values(participantsData as Record<string, ParticipantRecord>);
- const decrypted = await Promise.all(rawParticipants.map(async (p) => {
+ const friendsData = snapshot.val();
+ if (friendsData) {
+ const rawFriends = Object.values(friendsData as Record<string, FriendRecord>);
+ const decrypted = await Promise.all(rawFriends.map(async (p) => {
if (p.encryptedState && roomKey) {
try {
return await decryptJson(roomKey, p.encryptedState);
} catch (err) {
- console.error('Failed to decrypt participant state', p.id, err);
+ console.error('Failed to decrypt friend state', p.id, err);
return null;
}
}
@@ -45,22 +45,22 @@ export function subscribeToParticipants({ db, roomId, roomKey, onUpdate }) {
const { encryptedPosition: _stripped, ...rest } = p;
return { ...rest, position };
} catch (err) {
- console.error('Failed to decrypt legacy participant position', p.id, err);
+ console.error('Failed to decrypt legacy friend position', p.id, err);
return null;
}
}
return p;
}));
if (version !== snapshotVersion) return;
- participants.length = 0;
- participants.push(...decrypted.filter(Boolean));
+ friends.length = 0;
+ friends.push(...decrypted.filter(Boolean));
} else {
if (version !== snapshotVersion) return;
- participants.length = 0;
+ friends.length = 0;
}
- onUpdate(participants);
+ onUpdate(friends);
});
- return { participants };
+ return { friends };
}
diff --git a/src/index.html b/src/index.html
index f37787b..8c81c87 100644
--- a/src/index.html
+++ b/src/index.html
@@ -4,27 +4,30 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>North Star</title>
+ <title>Friend Compass</title>
<!-- Share card (Open Graph + Twitter/X) -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Someone wants you to know where they are 📍" />
<meta property="og:description"
- content="North Star shows live arrows pointing toward your friends. No account needed, end-to-end encrypted, works on any phone." />
+ content="Friend Compass shows live arrows pointing toward your friends. No account needed, end-to-end encrypted, works on any phone." />
<meta property="og:url" content="https://pavelsafronov.gitlab.io/north-star/" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Someone wants you to know where they are 📍" />
<meta name="twitter:description"
- content="North Star shows live arrows pointing toward your friends. No account needed, end-to-end encrypted, works on any phone." />
+ content="Friend Compass shows live arrows pointing toward your friends. No account needed, end-to-end encrypted, works on any phone." />
<meta name="description"
- content="North Star shows live arrows pointing toward your friends. No account needed, end-to-end encrypted, works on any phone." />
+ content="Friend Compass shows live arrows pointing toward your friends. No account needed, end-to-end encrypted, works on any phone." />
<link rel="stylesheet" href="style.css" />
</head>
<body>
- <p class="app-title"><a href="https://pavelsafronov.gitlab.io/north-star/">North Star</a></p>
+ <p class="app-title">
+ <img src="compass.png" alt="Compass icon" class="app-icon" />
+ <a href="https://pavelsafronov.gitlab.io/north-star/">Friend Compass</a>
+ </p>
<div class="room-header">
<p id="room-current" class="room-current"></p>
<a id="create-room-link" href="https://pavelsafronov.gitlab.io/north-star/">Create New Room</a>
@@ -39,17 +42,22 @@
</div>
<div id="content">
<div class="panel-actions">
- <button id="calibrate-heading-link" class="participants-toggle" type="button">Enable Compass</button>
- <button id="participants-toggle" class="participants-toggle" type="button" aria-expanded="true">Hide
- Participants</button>
+ <button id="calibrate-heading-link" class="friends-toggle" type="button">Enable Compass</button>
+ <button id="friends-toggle" class="friends-toggle" type="button" aria-expanded="true">Hide
+ Friends</button>
</div>
- <aside id="participants-panel" class="participants-panel" aria-label="Participants"></aside>
+ <aside id="friends-panel" class="friends-panel" aria-label="Friends"></aside>
</div>
<div class="room-links">
<div id="invite-room-qr-list" class="room-qr-list" aria-label="Room invite QR codes"></div>
</div>
<div>
<p id="debug"></p>
+ <a href="https://www.flaticon.com/free-icons/tools-and-utensils" title="Tools and utensils icons">Tools and
+ utensils
+ icons created by Icongeek26 - Flaticon</a>
+ <a href="https://www.flaticon.com/free-icons/arrow" title="arrow icons">Arrow icons created by Freepik -
+ Flaticon</a>
</div>
<script src="app.ts" type="module"></script>
</body>
diff --git a/src/participants-panel.ts b/src/participants-panel.ts
index c814ed4..b3dc9c6 100644
--- a/src/participants-panel.ts
+++ b/src/participants-panel.ts
@@ -8,9 +8,9 @@ export const createParticipantsPanelController = ({
selfParticipant,
selfPosition
}) => {
- const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'north-star-participant-visibility';
- const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'north-star-participants-panel-visible';
- const PARTICIPANT_ORDER_STORAGE_KEY = 'north-star-participant-order';
+ const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'friend-compass-friend-visibility';
+ const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'friend-compass-friends-panel-visible';
+ const PARTICIPANT_ORDER_STORAGE_KEY = 'friend-compass-friend-order';
const participantVisibility = (() => {
try {
@@ -132,7 +132,7 @@ export const createParticipantsPanelController = ({
participantsPanel.classList.toggle('is-hidden', !participantsPanelVisible);
}
if (participantsToggle) {
- participantsToggle.textContent = participantsPanelVisible ? 'Hide Participants' : 'Show Participants';
+ participantsToggle.textContent = participantsPanelVisible ? 'Hide Friends' : 'Show Friends';
participantsToggle.setAttribute('aria-expanded', String(participantsPanelVisible));
}
diff --git a/src/renderer.ts b/src/renderer.ts
index 9ae5659..c4565a1 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -5,20 +5,20 @@ type ArrowElement = HTMLElement & { currentAngle: number | null };
export const renderArrows = ({
content,
arrow,
- participantsToRender,
- selfParticipant,
+ friendsToRender,
+ selfFriend,
selfHeading,
- isParticipantVisible,
+ isFriendVisible,
getArrowDisplayColor
}) => {
const activeArrowIds = new Set(
- participantsToRender
- .filter((target) => target.id !== selfParticipant.id)
+ friendsToRender
+ .filter((target) => target.id !== selfFriend.id)
.map((target) => `arrow-${target.id}`)
);
- for (const target of participantsToRender) {
- if (target.id === selfParticipant.id) continue;
+ for (const target of friendsToRender) {
+ if (target.id === selfFriend.id) continue;
let existingArrow = document.getElementById(`arrow-${target.id}`) as ArrowElement | null;
if (!existingArrow) {
@@ -42,15 +42,15 @@ export const renderArrows = ({
content.appendChild(existingArrow);
- if (!isParticipantVisible(target.id)) {
+ if (!isFriendVisible(target.id)) {
existingArrow.style.display = 'none';
continue;
}
existingArrow.style.display = 'block';
- const dx = target.position.lon - selfParticipant.position.lon;
- const dy = target.position.lat - selfParticipant.position.lat;
+ 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);
const targetAngle = normalizeAngle(rawAngle - selfHeading);
diff --git a/src/self-profile.ts b/src/self-profile.ts
index 0b2cf48..e72ebce 100644
--- a/src/self-profile.ts
+++ b/src/self-profile.ts
@@ -22,18 +22,18 @@ const resolveInitialSelfColor = () => {
};
/**
- * Creates the selfParticipant object, populates form inputs, and wires
+ * Creates the selfFriend object, populates form inputs, and wires
* name/color change handlers.
*
* @param {object} opts
* @param {HTMLElement} opts.nameForm
* @param {HTMLInputElement} opts.selfNameInput
* @param {HTMLInputElement} opts.selfColorInput
- * @param {string} opts.uid - Firebase UID used as participant ID
+ * @param {string} opts.uid - Firebase UID used as friend ID
* @param {object} opts.selfPosition - Shared { lat, lon } reference (mutated externally)
* @param {function} opts.syncSelf - async () => void, called after name/color changes
* @param {function} opts.requestRender - triggers a render frame
- * @returns {{ selfParticipant: object }}
+ * @returns {{ selfFriend: object }}
*/
export async function initSelfProfile({
nameForm,
@@ -47,7 +47,7 @@ export async function initSelfProfile({
const initialSelfName = await resolveInitialSelfName();
const initialSelfColor = resolveInitialSelfColor();
- const selfParticipant = {
+ const selfFriend = {
id: uid,
name: initialSelfName,
color: initialSelfColor,
@@ -55,16 +55,16 @@ export async function initSelfProfile({
timestamp: Date.now()
};
- if (selfNameInput) selfNameInput.value = selfParticipant.name;
- if (selfColorInput) selfColorInput.value = selfParticipant.color;
+ if (selfNameInput) selfNameInput.value = selfFriend.name;
+ if (selfColorInput) selfColorInput.value = selfFriend.color;
const applySelfName = async (rawName) => {
const nextName = normalizeName(rawName) || await createRandomUserName();
- if (nextName === selfParticipant.name) {
+ if (nextName === selfFriend.name) {
if (selfNameInput) selfNameInput.value = nextName;
return;
}
- selfParticipant.name = nextName;
+ selfFriend.name = nextName;
localStorage.setItem(NAME_STORAGE_KEY, nextName);
if (selfNameInput) selfNameInput.value = nextName;
await syncSelf();
@@ -73,11 +73,11 @@ export async function initSelfProfile({
const applySelfColor = async (rawColor) => {
const nextColor = normalizeColor(rawColor) || createRandomColor();
- if (nextColor === selfParticipant.color) {
+ if (nextColor === selfFriend.color) {
if (selfColorInput) selfColorInput.value = nextColor;
return;
}
- selfParticipant.color = nextColor;
+ selfFriend.color = nextColor;
localStorage.setItem(COLOR_STORAGE_KEY, nextColor);
if (selfColorInput) selfColorInput.value = nextColor;
await syncSelf();
@@ -87,8 +87,8 @@ export async function initSelfProfile({
if (nameForm) {
nameForm.addEventListener('submit', async (event) => {
event.preventDefault();
- await applySelfName(selfNameInput ? selfNameInput.value : selfParticipant.name);
- await applySelfColor(selfColorInput ? selfColorInput.value : selfParticipant.color);
+ await applySelfName(selfNameInput ? selfNameInput.value : selfFriend.name);
+ await applySelfColor(selfColorInput ? selfColorInput.value : selfFriend.color);
if (selfNameInput) selfNameInput.blur();
});
}
@@ -105,5 +105,5 @@ export async function initSelfProfile({
});
}
- return { selfParticipant };
+ return { selfFriend };
}
diff --git a/src/style.css b/src/style.css
index 1d6532b..cbfaa0f 100644
--- a/src/style.css
+++ b/src/style.css
@@ -29,6 +29,13 @@ a {
text-shadow: none;
}
+.app-icon {
+ width: 1em;
+ height: 1em;
+ vertical-align: middle;
+ margin-right: 0.15em;
+}
+
.app-title {
margin: 0;
font-size: clamp(28px, 5.2vmin, 56px);
@@ -244,7 +251,7 @@ body.dark .dark-mode-toggle {
z-index: 4;
}
-.participants-toggle {
+.friends-toggle {
padding: 6px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
@@ -255,22 +262,22 @@ body.dark .dark-mode-toggle {
cursor: pointer;
}
-.participants-toggle:disabled {
+.friends-toggle:disabled {
cursor: not-allowed;
opacity: 0.75;
}
-.participants-toggle.compass-required {
+.friends-toggle.compass-required {
border: 2px solid #ef4444;
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.35);
}
-.participants-toggle.compass-error {
+.friends-toggle.compass-error {
border: 2px solid #f97316;
background: rgba(255, 237, 213, 0.96);
}
-.participants-panel {
+.friends-panel {
position: absolute;
right: 10px;
top: 50%;
@@ -287,17 +294,17 @@ body.dark .dark-mode-toggle {
z-index: 3;
}
-.participants-panel.is-hidden {
+.friends-panel.is-hidden {
display: none;
}
-.participants-panel h3 {
+.friends-panel h3 {
margin: 0 0 6px;
font-size: 14px;
font-weight: 700;
}
-.participant-row {
+.friend-row {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 8px;
@@ -306,7 +313,7 @@ body.dark .dark-mode-toggle {
font-size: 13px;
}
-.participant-name-badge {
+.friend-name-badge {
display: flex;
align-items: center;
justify-content: space-between;
@@ -320,7 +327,7 @@ body.dark .dark-mode-toggle {
text-shadow: none;
}
-.participant-name {
+.friend-name {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
@@ -328,13 +335,13 @@ body.dark .dark-mode-toggle {
text-overflow: ellipsis;
}
-.participant-map-link {
+.friend-map-link {
font-size: 17px;
line-height: 1;
text-decoration: none;
}
-.participant-drag-handle {
+.friend-drag-handle {
width: 24px;
height: 24px;
border: 1px solid #cbd5e1;
@@ -348,19 +355,19 @@ body.dark .dark-mode-toggle {
touch-action: none;
}
-.participant-drag-handle:active {
+.friend-drag-handle:active {
cursor: grabbing;
}
-.participant-row.drag-over-before {
+.friend-row.drag-over-before {
box-shadow: inset 0 2px 0 rgba(59, 130, 246, 0.9);
}
-.participant-row.drag-over-after {
+.friend-row.drag-over-after {
box-shadow: inset 0 -2px 0 rgba(59, 130, 246, 0.9);
}
-.participant-row.is-dragging {
+.friend-row.is-dragging {
opacity: 0.55;
}
@@ -489,22 +496,22 @@ body.dark .name-form input[type="color"] {
border-color: #475569;
}
-body.dark .participants-toggle {
+body.dark .friends-toggle {
background: rgba(30, 41, 59, 0.96);
border-color: #334155;
color: #f1f5f9;
}
-body.dark .participants-toggle.compass-error {
+body.dark .friends-toggle.compass-error {
background: rgba(124, 45, 18, 0.85);
}
-body.dark .participants-panel {
+body.dark .friends-panel {
background: rgba(30, 41, 59, 0.96);
border-color: #334155;
}
-body.dark .participant-drag-handle {
+body.dark .friend-drag-handle {
background: #0f172a;
border-color: #475569;
color: #94a3b8;
@@ -532,7 +539,7 @@ body.dark .arrow-label {
top: 6px;
}
- .participants-panel {
+ .friends-panel {
width: min(88vw, 320px);
right: 6px;
top: 38px;
@@ -541,12 +548,12 @@ body.dark .arrow-label {
padding: 6px;
}
- .participant-row {
+ .friend-row {
grid-template-columns: auto auto minmax(0, 1fr);
gap: 6px;
}
- .participant-drag-handle {
+ .friend-drag-handle {
width: 22px;
height: 22px;
font-size: 12px;
diff --git a/tsconfig.json b/tsconfig.json
index f7063af..ed4f24c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,13 +3,22 @@
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
- "lib": ["ESNext", "DOM", "DOM.Iterable"],
- "types": ["vite/client"],
+ "lib": [
+ "ESNext",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "types": [
+ "vite/client"
+ ],
"allowJs": false,
"strict": false,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true
},
- "include": ["src/**/*.ts", "vite.config.js"]
-}
+ "include": [
+ "src/**/*.ts",
+ "vite.config.js"
+ ]
+}
\ No newline at end of file