fuzzyworld

north-star / commit

added a participant panel

fb3b6f32954930fc768e432b78ef97ea18648ac3 · Pavel Safronov · 2026-03-14 15:16:47 -0700 · parents 4661a9b6c90e

added a participant panel
 readme.md      |   7 ++++
 src/app.js     | 128 +++++++++++++++++++++++++++++++++++++++++++++++++--------
 src/index.html |   1 +
 src/style.css  |  71 +++++++++++++++++++++++++++++++-
 4 files changed, 188 insertions(+), 19 deletions(-)

diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..09027f0
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,7 @@
+# North Star
+
+## Links
+
+GitLab: https://pavelsafronov.gitlab.io/north-star/#room=7c49d0a8
+
+Local: http://127.0.0.1:5500/src/index.html#room=7c49d0a8
diff --git a/src/app.js b/src/app.js
index 1c08b20..3ec084f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,6 +1,5 @@
 import { onValue } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js";
 import { initializeApp } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js";
-import { getFirestore, collection, getDocs } from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-firestore.js';
 import {
     getAuth,
     signInAnonymously,
@@ -16,6 +15,7 @@ const inviteRoomLink = document.getElementById('invite-room-link');
 const joinRoomLink = document.getElementById('join-room-link');
 const inviteRoomStatus = document.getElementById('invite-room-status');
 const createRoomLink = document.getElementById('create-room-link');
+const participantsPanel = document.getElementById('participants-panel');
 const logDebug = (message) => {
     debugParagraph.innerText = message;
 }
@@ -208,11 +208,23 @@ if (joinRoomLink) {
 
 const NAME_STORAGE_KEY = 'north-star-self-name';
 const COLOR_STORAGE_KEY = 'north-star-self-color';
+const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'north-star-participant-visibility';
 const normalizeName = (value) => (value || '').trim().slice(0, 32);
 const normalizeColor = (value) => {
     const candidate = (value || '').trim().toLowerCase();
     return /^#[0-9a-f]{6}$/.test(candidate) ? candidate : '';
 };
+const getReadableTextColor = (hexColor) => {
+    const normalized = normalizeColor(hexColor);
+    if (!normalized) return '#0f172a';
+
+    const r = parseInt(normalized.slice(1, 3), 16);
+    const g = parseInt(normalized.slice(3, 5), 16);
+    const b = parseInt(normalized.slice(5, 7), 16);
+    const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+
+    return luminance > 145 ? '#0f172a' : '#f8fafc';
+};
 const createRandomUserName = () => `User ${Math.floor(10000 + Math.random() * 90000)}`;
 const createRandomColor = () => `#${Math.floor(Math.random() * 0x1000000).toString(16).padStart(6, '0')}`;
 const resolveInitialSelfName = () => {
@@ -236,6 +248,20 @@ const resolveInitialSelfColor = () => {
 
 const initialSelfName = resolveInitialSelfName();
 const initialSelfColor = resolveInitialSelfColor();
+const participantVisibility = (() => {
+    try {
+        const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY);
+        return raw ? JSON.parse(raw) : {};
+    } catch {
+        return {};
+    }
+})();
+
+const isParticipantVisible = (participantId) => participantVisibility[participantId] !== false;
+const setParticipantVisibility = (participantId, visible) => {
+    participantVisibility[participantId] = visible;
+    localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(participantVisibility));
+};
 
 const selfParticipant = {
     id: uid,
@@ -409,10 +435,54 @@ onValue(participantsRef, (snapshot) => {
     }
 
     console.debug('Updated participants array:', participants);
+    renderParticipantControls();
 });
 
+const renderParticipantControls = () => {
+    const participantsToRender = getParticipantsToRender();
 
-const render = () => {
+    participantsPanel.textContent = '';
+    const heading = document.createElement('h3');
+    heading.textContent = 'Participants';
+    participantsPanel.appendChild(heading);
+
+    for (const participant of participantsToRender) {
+        const row = document.createElement('label');
+        row.className = 'participant-row';
+
+        const checkbox = document.createElement('input');
+        checkbox.type = 'checkbox';
+        checkbox.checked = isParticipantVisible(participant.id);
+        checkbox.addEventListener('change', () => {
+            setParticipantVisibility(participant.id, checkbox.checked);
+        });
+
+        const nameWrapper = document.createElement('div');
+        nameWrapper.className = 'participant-name-badge';
+        nameWrapper.style.backgroundColor = participant.color;
+        nameWrapper.style.color = getReadableTextColor(participant.color);
+
+        const nameText = document.createElement('span');
+        nameText.className = 'participant-name';
+        nameText.textContent = participant.name;
+
+        nameWrapper.appendChild(nameText);
+
+        const mapLink = document.createElement('a');
+        mapLink.className = 'participant-map-link';
+        mapLink.href = `https://www.google.com/maps?q=${participant.position.lat},${participant.position.lon}`;
+        mapLink.target = '_blank';
+        mapLink.rel = 'noopener noreferrer';
+        mapLink.textContent = 'Map';
+
+        row.appendChild(checkbox);
+        row.appendChild(nameWrapper);
+        row.appendChild(mapLink);
+        participantsPanel.appendChild(row);
+    }
+};
+
+const getParticipantsToRender = () => {
     const participantsToRender = participants.filter(p => p.id !== selfParticipant.id);
 
     // Add Space Needle and Cascadia as a test participant if there are no other participants (for testing purposes, local only)
@@ -434,24 +504,39 @@ const render = () => {
             timestamp: Date.now()
         });
     }
+    // Mt Rainier: 46.852200555312315, -121.76028300395946
+    if (participantsToRender.findIndex(p => p.id === 'mt-rainier') === -1) {
+        participantsToRender.push({
+            id: 'mt-rainier',
+            name: 'Mt Rainier',
+            color: '#c2c2e8',
+            position: { lat: 46.852200555312315, lon: -121.76028300395946 },
+            timestamp: Date.now()
+        });
+    }
 
-    // // For local testing, add a "stalker" participant that moves in a circle around the self participant. This is useful for testing the arrow rotation without needing multiple devices. The stalker will NOT be added as a participant in the database, so it will show up only on the local device and won't affect other participants.
-    // const stalkerAngle = (Date.now() / 10000) % (2 * Math.PI); // angle in radians that changes over time
-    // // console.debug('Stalker angle (radians):', stalkerAngle);
-    // const stalkerDistance = 0.001; // distance in degrees (about 111 meters)
-    // const stalkerPosition = {
-    //     lat: selfPosition.lat + stalkerDistance * Math.cos(stalkerAngle),
-    //     lon: selfPosition.lon + stalkerDistance * Math.sin(stalkerAngle)
-    // };
-    // const testParticipant = {
-    //     id: 'test-stalker',
-    //     name: 'Test Stalker',
-    //     color: '#ff0000',
-    //     position: stalkerPosition,
-    //     timestamp: Date.now()
-    // };
-    // participantsToRender.push(testParticipant);
+    // For local testing, add a "stalker" participant that moves in a circle around the self participant. This is useful for testing the arrow rotation without needing multiple devices. The stalker will NOT be added as a participant in the database, so it will show up only on the local device and won't affect other participants.
+    const stalkerAngle = (Date.now() / 10000) % (2 * Math.PI); // angle in radians that changes over time
+    // console.debug('Stalker angle (radians):', stalkerAngle);
+    const stalkerDistance = 0.001; // distance in degrees (about 111 meters)
+    const stalkerPosition = {
+        lat: selfPosition.lat + stalkerDistance * Math.cos(stalkerAngle),
+        lon: selfPosition.lon + stalkerDistance * Math.sin(stalkerAngle)
+    };
+    const testParticipant = {
+        id: 'test-stalker',
+        name: 'Test Stalker',
+        color: '#ff0000',
+        position: stalkerPosition,
+        timestamp: Date.now()
+    };
+    participantsToRender.push(testParticipant);
+
+    return participantsToRender;
+};
 
+const render = () => {
+    const participantsToRender = getParticipantsToRender();
 
     // For each participant, calculate the angle from self to the participant, and rotate the arrow accordingly. Also set the color of the arrow to the participant's color.
     // Skip self participant, we don't want to render an arrow for ourselves.
@@ -476,6 +561,13 @@ const render = () => {
             existingArrow = newArrow;
         }
 
+        if (!isParticipantVisible(target.id)) {
+            existingArrow.style.display = 'none';
+            continue;
+        }
+
+        existingArrow.style.display = 'block';
+
         // Rotate the arrow toward the other participant and use their color.
         const dx = target.position.lon - selfParticipant.position.lon;
         const dy = target.position.lat - selfParticipant.position.lat;
diff --git a/src/index.html b/src/index.html
index de8afb0..91ed3ae 100644
--- a/src/index.html
+++ b/src/index.html
@@ -25,6 +25,7 @@
     </form>
     <div id="default-arrow" style="display: none;" class="arrow">⇡</div>
     <div id="content">
+        <aside id="participants-panel" class="participants-panel" aria-label="Participants"></aside>
     </div>
     <div>
         <p id="debug"></p>
diff --git a/src/style.css b/src/style.css
index 93183b3..e4ba1ae 100644
--- a/src/style.css
+++ b/src/style.css
@@ -154,10 +154,60 @@ a {
     min-height: 220px;
     display: block;
     overflow: hidden;
-    pointer-events: none;
+    pointer-events: auto;
     z-index: 1;
 }
 
+.participants-panel {
+    position: absolute;
+    right: 10px;
+    top: 50%;
+    transform: translateY(-50%);
+    width: min(44vw, 280px);
+    max-height: calc(100% - 12px);
+    overflow: auto;
+    padding: 8px;
+    border-radius: 10px;
+    background: rgba(15, 23, 42, 0.65);
+    backdrop-filter: blur(6px);
+    z-index: 3;
+}
+
+.participants-panel h3 {
+    margin: 0 0 6px;
+    font-size: 14px;
+    font-weight: 700;
+}
+
+.participant-row {
+    display: grid;
+    grid-template-columns: auto 1fr auto;
+    gap: 8px;
+    align-items: center;
+    margin-bottom: 6px;
+    font-size: 13px;
+}
+
+.participant-name-badge {
+    display: inline-block;
+    width: 100%;
+    padding: 5px 8px;
+    border-radius: 8px;
+    border: 1px solid rgba(255, 255, 255, 0.35);
+    font-weight: 700;
+    text-shadow: none;
+}
+
+.participant-name {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.participant-map-link {
+    font-size: 12px;
+}
+
 .arrow {
     position: absolute;
     top: 50%;
@@ -168,6 +218,8 @@ a {
     transform: rotate(0deg);
     transition: transform 160ms ease;
     user-select: none;
+    pointer-events: none;
+    z-index: 1;
     -webkit-text-stroke: 2px rgba(0, 0, 0, 0.95);
     text-shadow:
         0 0 1px rgba(0, 0, 0, 0.95),
@@ -210,4 +262,21 @@ a {
     transform: translateX(-50%);
     font-size: 14px;
     color: #94a3b8;
+}
+
+@media (max-width: 720px) {
+    .participants-panel {
+        width: min(52vw, 220px);
+        right: 6px;
+        padding: 6px;
+    }
+
+    .participant-row {
+        grid-template-columns: auto 1fr;
+    }
+
+    .participant-map-link {
+        grid-column: 2;
+        justify-self: start;
+    }
 }
\ No newline at end of file