fuzzyworld

north-star / commit

starting to use firebase, adding more arrows, adding a way to customize name and color

dcd59aa1eefe0170c895e35ea6d4a7c71c2c2576 · Pavel Safronov · 2026-03-14 13:15:09 -0700 · parents de4dbd8a6dc7

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 {