north-star / commit
starting to use firebase, adding more arrows, adding a way to customize name and color
.gitlab-ci.yaml | 7 -- src/app.js | 366 +++++++++++++++++++++++++++++++++++++++++--------------- src/index.html | 16 ++- src/style.css | 123 ++++++++++++++++++- 4 files changed, 403 insertions(+), 109 deletions(-) diff --git a/.gitlab-ci.yaml b/.gitlab-ci.yaml deleted file mode 100644 index 6b41688..0000000 --- a/.gitlab-ci.yaml +++ /dev/null @@ -1,7 +0,0 @@ -pages: - script: - - mkdir -p public - - cp ./src/* ./public/ -R - artifacts: - paths: - - public diff --git a/src/app.js b/src/app.js index 539a384..14232c4 100644 --- a/src/app.js +++ b/src/app.js @@ -1,9 +1,22 @@ +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, +} from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-auth.js'; +import { getDatabase, ref, set, onDisconnect } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js"; + const debugParagraph = document.getElementById('debug'); +const nameForm = document.getElementById('name-form'); +const selfNameInput = document.getElementById('self-name-input'); +const selfColorInput = document.getElementById('self-color-input'); const logDebug = (message) => { debugParagraph.innerText = message; } -const arrow = document.getElementById('arrow'); -let currentArrowAngle = null; + +const arrow = document.getElementById('default-arrow'); +document.head.appendChild(Object.assign(document.createElement("link"), { rel: "icon", href: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>" + arrow.innerText + "</text></svg>" })) const getShortestDelta = (fromAngle, toAngle) => ((toAngle - fromAngle + 540) % 360) - 180; /** @@ -32,54 +45,163 @@ const getShortestDelta = (fromAngle, toAngle) => ((toAngle - fromAngle + 540) % * } */ -/** - * @typedef {Object} AppState - * @property {string} roomId - * @property {string} selfId - * @property {Array<Participant>} participants - * - * { - * roomId: 'room123', - * selfId: 'abc123', - * participants: [ - * { - * id: 'abc123', - * name: 'Alice', - * color: '#ff0000', - * position: { - * lat: 37.7749, - * lon: -122.4194 - * }, - * timestamp: 1625247600000 - * }, - * { - * id: 'def456', - * name: 'Bob', - * color: '#00ff00', - * position: { - * lat: 37.7749, - * lon: -122.4194 - * }, - * timestamp: 1625247600000 - * } - * ]) - * } - */ - // Space Needle: 47.62055039884746, -122.34928833200904 +const spaceNeedle = { lat: 47.62055039884746, lon: -122.34928833200904 }; +// Cascadia Pizza: 47.62664023883763, -122.1661814443633 +const cascadiaPizza = { lat: 47.62664023883763, lon: -122.1661814443633 }; + const selfPosition = { lat: 47.62055039884746, lon: -122.34928833200904 }; -navigator.geolocation.watchPosition((pos) => { +// Heading, assume north orientation by default, but if we get a heading from the device, use that to rotate the arrow accordingly +let selfHeading = 0; + +const firebaseConfig = { + apiKey: "AIzaSyA4G7e13Gt1XAIds1Y63X_RFw-2O48iCLM", + authDomain: "north-star-e5dee.firebaseapp.com", + projectId: "north-star-e5dee", + storageBucket: "north-star-e5dee.firebasestorage.app", + messagingSenderId: "999461150314", + appId: "1:999461150314:web:53f3c6216dd4b0468bf413", + databaseURL: "https://north-star-e5dee-default-rtdb.firebaseio.com/" +}; +initializeApp(firebaseConfig); + +const app = initializeApp(firebaseConfig); +const auth = getAuth(app); +const db = getDatabase(app); + +const authSignInResponse = await signInAnonymously(auth); +console.debug('Signed in anonymously:', authSignInResponse); +const uid = authSignInResponse.user.uid; +console.debug('User ID:', uid); + +let roomId = location.hash.split('room=')[1]; +if (!roomId) { + roomId = crypto.randomUUID().slice(0, 8); + location.hash = 'room=' + roomId; +} +console.debug('Room ID:', roomId); + +const NAME_STORAGE_KEY = 'north-star-self-name'; +const COLOR_STORAGE_KEY = 'north-star-self-color'; +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 createRandomUserName = () => `User ${Math.floor(10000 + Math.random() * 90000)}`; +const createRandomColor = () => `#${Math.floor(Math.random() * 0x1000000).toString(16).padStart(6, '0')}`; +const resolveInitialSelfName = () => { + const savedName = normalizeName(localStorage.getItem(NAME_STORAGE_KEY)); + + if (savedName) return savedName; + + const generatedName = createRandomUserName(); + localStorage.setItem(NAME_STORAGE_KEY, generatedName); + return generatedName; +}; +const resolveInitialSelfColor = () => { + const savedColor = normalizeColor(localStorage.getItem(COLOR_STORAGE_KEY)); + + if (savedColor) return savedColor; + + const generatedColor = createRandomColor(); + localStorage.setItem(COLOR_STORAGE_KEY, generatedColor); + return generatedColor; +}; + +const initialSelfName = resolveInitialSelfName(); +const initialSelfColor = resolveInitialSelfColor(); + +const selfParticipant = { + id: uid, + name: initialSelfName, + color: initialSelfColor, + position: selfPosition, + timestamp: Date.now() +}; + +const participantRef = ref(db, `rooms/${roomId}/participants/${uid}`); +onDisconnect(participantRef).remove(); + +async function syncSelfParticipant() { + selfParticipant.timestamp = Date.now(); + console.debug('Syncing self participant:', selfParticipant); + try { + await set(participantRef, selfParticipant); + } catch (err) { + console.error('Error syncing participant in database', err); + } +} + +if (selfNameInput) { + selfNameInput.value = selfParticipant.name; +} +if (selfColorInput) { + selfColorInput.value = selfParticipant.color; +} + +const applySelfName = async (rawName) => { + const nextName = normalizeName(rawName) || createRandomUserName(); + + if (nextName === selfParticipant.name) { + if (selfNameInput) selfNameInput.value = nextName; + return; + } + + selfParticipant.name = nextName; + localStorage.setItem(NAME_STORAGE_KEY, nextName); + if (selfNameInput) selfNameInput.value = nextName; + await syncSelfParticipant(); + render(); +}; + +const applySelfColor = async (rawColor) => { + const nextColor = normalizeColor(rawColor) || createRandomColor(); + + if (nextColor === selfParticipant.color) { + if (selfColorInput) selfColorInput.value = nextColor; + return; + } + + selfParticipant.color = nextColor; + localStorage.setItem(COLOR_STORAGE_KEY, nextColor); + if (selfColorInput) selfColorInput.value = nextColor; + await syncSelfParticipant(); + render(); +}; + +if (nameForm) { + nameForm.addEventListener('submit', async (event) => { + event.preventDefault(); + await applySelfName(selfNameInput ? selfNameInput.value : selfParticipant.name); + await applySelfColor(selfColorInput ? selfColorInput.value : selfParticipant.color); + if (selfNameInput) selfNameInput.blur(); + }); +} + +if (selfNameInput) { + selfNameInput.addEventListener('change', async () => { + await applySelfName(selfNameInput.value); + }); +} +if (selfColorInput) { + selfColorInput.addEventListener('change', async () => { + await applySelfColor(selfColorInput.value); + }); +} + +navigator.geolocation.watchPosition(async (pos) => { selfPosition.lat = pos.coords.latitude; selfPosition.lon = pos.coords.longitude; + selfParticipant.position = { ...selfPosition }; + await syncSelfParticipant(); render(); }, (err) => { console.error('Error getting geolocation', err); }); -// Heading, assume north orientation by default, but if we get a heading from the device, use that to rotate the arrow accordingly -let selfHeading = 0; window.addEventListener('deviceorientationabsolute', (event) => { if (event.absolute && event.alpha !== null) { selfHeading = event.alpha; @@ -87,76 +209,126 @@ window.addEventListener('deviceorientationabsolute', (event) => { } }); -const getTestParticipantPosition = (angle) => { - const radius = 0.001; // ~111 meters - return { - lat: selfPosition.lat + radius * Math.cos(angle), - lon: selfPosition.lon + radius * Math.sin(angle) - }; -} +const participantsRef = ref(db, `rooms/${roomId}/participants`); +// onValue(participantsRef, (snapshot) => { +// const participants = snapshot.val(); // object of uid → participant +// console.log(participants); +// // update arrows in your UI here +// }); +const participants = []; +onValue(participantsRef, (snapshot) => { + console.debug('Participants data updated:', snapshot.val()); -const createId = () => Math.random().toString(36).substr(2, 9); -const selfParticipant = { - id: createId(), - name: 'You', - color: '#a2b8ed', - position: selfPosition, - timestamp: Date.now() -}; -const testParticipant = { - id: 'test', - name: 'Jane', - color: '#ff0000', - position: getTestParticipantPosition(0), - timestamp: Date.now() -}; + const participantsData = snapshot.val(); // object of uid → participant + if (participantsData) { + participants.length = 0; // clear the array while keeping the reference + participants.push(...Object.values(participantsData)); // add new participants + } else { + console.warn('No participants data'); + // return; + } + + console.debug('Updated participants array:', participants); + render(); +}); -const appState = { - roomId: createId(), - selfId: selfParticipant.id, - participants: [selfParticipant, testParticipant] -}; const render = () => { - const self = appState.participants.find((p) => p.id === appState.selfId); - const target = appState.participants.find((p) => p.id !== appState.selfId); + const participantsToRender = participants.filter(p => p.id !== selfParticipant.id); - if (!self || !target) return; + // Add Space Needle and Cascadia as a test participant if there are no other participants (for testing purposes, local only) + if (participantsToRender.findIndex(p => p.id === 'space-needle') === -1) { + participantsToRender.push({ + id: 'space-needle', + name: 'Space Needle', + color: '#f211c9', + position: spaceNeedle, + timestamp: Date.now() + }); + } + if (participantsToRender.findIndex(p => p.id === 'cascadia-pizza') === -1) { + participantsToRender.push({ + id: 'cascadia-pizza', + name: 'Cascadia Pizza', + color: '#00ff00', + position: cascadiaPizza, + timestamp: Date.now() + }); + } - // Rotate the arrow toward the other participant and use their color. - const dx = target.position.lon - self.position.lon; - const dy = target.position.lat - self.position.lat; - const rawAngle = Math.atan2(dy, dx) * (180 / Math.PI) + 90; + // 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); - // Consider the heading of the device to rotate the arrow accordingly - const targetAngle = (rawAngle - selfHeading + 360) % 360; - if (currentArrowAngle === null) { - currentArrowAngle = targetAngle; - } else { - currentArrowAngle += getShortestDelta(currentArrowAngle, targetAngle) * 0.1; // smooth the rotation - } + // 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. + for (const target of participantsToRender) { + if (target.id === selfParticipant.id) continue; + + // create a new arrow element if it doesn't exist + // copy from 'default-arrow' element, but set id to 'arrow-PARTICIPANT_ID' and display to block + let existingArrow = document.getElementById('arrow-' + target.id); + if (!existingArrow) { + const newArrow = arrow.cloneNode(true); + newArrow.id = 'arrow-' + target.id; + newArrow.style.display = 'block'; + newArrow.currentAngle = null; // track the current angle for smooth rotation - arrow.style.transform = `rotate(${currentArrowAngle}deg)`; - arrow.style.color = target.color; + const arrowLabel = document.createElement('span'); + arrowLabel.className = 'arrow-label'; + arrowLabel.textContent = target.name; + newArrow.appendChild(arrowLabel); + + document.getElementById('content').appendChild(newArrow); + existingArrow = newArrow; + } + + // 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; + const rawAngle = Math.atan2(dy, dx) * (180 / Math.PI) + 90; + + // Consider the heading of the device to rotate the arrow accordingly + const targetAngle = (rawAngle - selfHeading + 360) % 360; + + let currentArrowAngle = existingArrow.currentAngle; + if (currentArrowAngle === null) { + currentArrowAngle = targetAngle; + } else { + currentArrowAngle += getShortestDelta(currentArrowAngle, targetAngle) * 0.1; // smooth the rotation + } + + existingArrow.currentAngle = currentArrowAngle; + existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`; + existingArrow.style.color = target.color; + + const arrowLabel = existingArrow.querySelector('.arrow-label'); + if (arrowLabel) { + arrowLabel.textContent = target.name; + arrowLabel.style.transform = `translate(-50%, -100%) rotate(${-currentArrowAngle}deg)`; + } + } // set the background color to self color - document.body.style.backgroundColor = self.color; + document.body.style.backgroundColor = selfParticipant.color; // output debug info - logDebug(`Self: ${self.position.lat.toFixed(5)}, ${self.position.lon.toFixed(5)} | Target: ${target.position.lat.toFixed(5)}, ${target.position.lon.toFixed(5)} | Heading: ${selfHeading.toFixed(2)}° | Arrow Angle: ${currentArrowAngle.toFixed(2)}°`); -}; - -// move the test participant in a circle around the self participant, updating every 1 seconds -const moveTestParticipant = () => { - let angle = 0; - setInterval(() => { - angle += Math.PI / 30; // increment angle - testParticipant.position = getTestParticipantPosition(angle); - testParticipant.timestamp = Date.now(); - render(); - }, 100); - render(); + logDebug(`${selfParticipant.name} | Self: ${selfParticipant.position.lat.toFixed(5)}, ${selfParticipant.position.lon.toFixed(5)} | Heading: ${selfHeading.toFixed(2)}° | Participants: ${participants.length}`); }; -moveTestParticipant(); \ No newline at end of file +await syncSelfParticipant(); +setInterval(render, 100); // re-render every 100ms to update arrow positions smoothly \ No newline at end of file diff --git a/src/index.html b/src/index.html index ebc8a80..5551059 100644 --- a/src/index.html +++ b/src/index.html @@ -10,13 +10,21 @@ </head> <body> - <p><a href="https://pavelsafronov.gitlab.io/north-star/">North Star</a></p> - <div id="arrow">⇡</div> - <!-- Footer that says the current site (https://pavelsafronov.gitlab.io/north-star/), and debug info --> + <p class="app-title"><a href="https://pavelsafronov.gitlab.io/north-star/">North Star</a></p> + <form id="name-form" class="name-form" autocomplete="off"> + <label for="self-name-input">You</label> + <input id="self-name-input" name="selfName" type="text" maxlength="32" enterkeyhint="done" /> + <label for="self-color-input">Color</label> + <input id="self-color-input" name="selfColor" type="color" /> + <button type="submit">Save</button> + </form> + <div id="default-arrow" style="display: none;" class="arrow">⇡</div> + <div id="content"> + </div> <div> <p id="debug"></p> </div> - <script src="app.js"></script> + <script src="app.js" type="module"></script> </body> </html> \ No newline at end of file diff --git a/src/style.css b/src/style.css index 7eb696e..acd6ffa 100644 --- a/src/style.css +++ b/src/style.css @@ -13,12 +13,133 @@ body { overflow: hidden; } -#arrow { +a { + color: #ffffff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); +} + +.app-title { + position: fixed; + top: max(10px, env(safe-area-inset-top)); + left: 50%; + transform: translateX(-50%); + margin: 0; + z-index: 11; + font-size: clamp(28px, 5.2vmin, 56px); + font-weight: 800; + line-height: 1; + letter-spacing: 0.02em; +} + +.app-title a { + text-decoration: none; +} + +.name-form { + position: fixed; + left: 50%; + bottom: max(56px, calc(env(safe-area-inset-bottom) + 56px)); + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.68); + backdrop-filter: blur(8px); + z-index: 10; +} + +.name-form label { + font-size: 14px; + font-weight: 600; + color: #e2e8f0; +} + +.name-form input { + width: min(54vw, 240px); + min-width: 130px; + padding: 10px 12px; + border: 1px solid #64748b; + border-radius: 10px; + background: rgba(248, 250, 252, 0.96); + color: #0f172a; + font-size: 16px; +} + +.name-form input[type="color"] { + width: 44px; + min-width: 44px; + height: 44px; + padding: 2px; + border-radius: 10px; + border: 1px solid #64748b; + background: rgba(248, 250, 252, 0.96); +} + +.name-form button { + padding: 10px 12px; + border: 0; + border-radius: 10px; + background: #0ea5e9; + color: #f8fafc; + font-weight: 700; + font-size: 14px; +} + +.name-form button:active { + transform: translateY(1px); +} + +#content { + position: fixed; + inset: 0; + pointer-events: none; +} + +.arrow { + position: absolute; + top: 50%; + left: 50%; + translate: -50% -50%; font-size: 70vmin; line-height: 1; transform: rotate(0deg); transition: transform 160ms ease; user-select: none; + -webkit-text-stroke: 2px rgba(0, 0, 0, 0.95); + text-shadow: + 0 0 1px rgba(0, 0, 0, 0.95), + 0 0 8px rgba(0, 0, 0, 0.35); +} + +.arrow-label { + position: absolute; + top: 6%; + left: 50%; + translate: -50% -100%; + font-family: system-ui, sans-serif; + font-size: clamp(18px, 3vmin, 34px); + font-weight: 600; + line-height: 1; + white-space: nowrap; + color: #ffffff; + -webkit-text-stroke: 0.25px rgba(0, 0, 0, 0.9); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85); +} + +#debug { + position: fixed; + left: 50%; + bottom: max(8px, env(safe-area-inset-bottom)); + transform: translateX(-50%); + margin: 0; + padding: 6px 10px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.45); + color: #f8fafc; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85); + z-index: 9; } .hint {