north-star / commit
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