fuzzyworld

north-star / commit

added ordering

d66a6d5521e197dd48adc1da563dc87fd4eb61a6 · Pavel Safronov · 2026-03-14 21:09:53 -0700 · parents e2694d9e47c0

added ordering
 src/app.js    | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 src/style.css |  38 +++++++++++++++++-
 2 files changed, 150 insertions(+), 10 deletions(-)

diff --git a/src/app.js b/src/app.js
index f1b11f7..fe06c90 100644
--- a/src/app.js
+++ b/src/app.js
@@ -250,6 +250,9 @@ 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 PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'north-star-participants-panel-visible';
+const PARTICIPANT_ORDER_STORAGE_KEY = 'north-star-participant-order';
+const NORTH_MARKER_ID = 'north-marker';
+const GUARDIAN_ANGEL_ID = 'test';
 const normalizeName = (value) => (value || '').trim().slice(0, 32);
 const normalizeColor = (value) => {
     const candidate = (value || '').trim().toLowerCase();
@@ -297,6 +300,75 @@ const participantVisibility = (() => {
         return {};
     }
 })();
+let participantOrder = (() => {
+    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 saveParticipantOrder = () => {
+    localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(participantOrder));
+};
+
+const getParticipantDefaultRank = (participantId) => {
+    if (participantId === NORTH_MARKER_ID) return 0;
+    if (participantId === GUARDIAN_ANGEL_ID || participantId === 'guardian-angel') return 1;
+    return 2;
+};
+
+const ensureParticipantOrder = (participantsToRender) => {
+    const ids = participantsToRender.map(participant => participant.id);
+    const idSet = new Set(ids);
+    const preserved = participantOrder.filter(id => idSet.has(id));
+    const preservedSet = new Set(preserved);
+    const missing = ids.filter(id => !preservedSet.has(id));
+
+    missing.sort((a, b) => {
+        const rankDelta = getParticipantDefaultRank(a) - getParticipantDefaultRank(b);
+        if (rankDelta !== 0) return rankDelta;
+        return a.localeCompare(b);
+    });
+
+    const nextOrder = [...preserved, ...missing];
+    const changed =
+        nextOrder.length !== participantOrder.length ||
+        nextOrder.some((id, index) => id !== participantOrder[index]);
+
+    if (changed) {
+        participantOrder = nextOrder;
+        saveParticipantOrder();
+    }
+
+    return participantOrder;
+};
+
+const sortParticipantsForDisplay = (participantsToRender) => {
+    const order = ensureParticipantOrder(participantsToRender);
+    const orderIndex = new Map(order.map((id, index) => [id, index]));
+
+    return [...participantsToRender].sort((a, b) => {
+        const aIndex = orderIndex.get(a.id);
+        const bIndex = orderIndex.get(b.id);
+        return aIndex - bIndex;
+    });
+};
+
+const moveParticipantInOrder = (participantId, direction) => {
+    const index = participantOrder.indexOf(participantId);
+    if (index < 0) return;
+
+    const nextIndex = index + direction;
+    if (nextIndex < 0 || nextIndex >= participantOrder.length) return;
+
+    [participantOrder[index], participantOrder[nextIndex]] = [participantOrder[nextIndex], participantOrder[index]];
+    saveParticipantOrder();
+    renderParticipantControls();
+    requestRender();
+};
 
 const getInitialParticipantsPanelVisible = () => {
     const saved = localStorage.getItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY);
@@ -526,8 +598,8 @@ const renderParticipantControls = () => {
     heading.textContent = 'Participants';
     participantsPanel.appendChild(heading);
 
-    for (const participant of participantsToRender) {
-        const row = document.createElement('label');
+    for (const [index, participant] of participantsToRender.entries()) {
+        const row = document.createElement('div');
         row.className = 'participant-row';
 
         const checkbox = document.createElement('input');
@@ -560,17 +632,47 @@ const renderParticipantControls = () => {
         row.appendChild(checkbox);
         nameWrapper.appendChild(mapLink);
         row.appendChild(nameWrapper);
+
+        const orderControls = document.createElement('div');
+        orderControls.className = 'participant-order-controls';
+
+        const moveUpButton = document.createElement('button');
+        moveUpButton.type = 'button';
+        moveUpButton.className = 'participant-order-button';
+        moveUpButton.textContent = '↑';
+        moveUpButton.title = `Move ${participant.name} up`;
+        moveUpButton.setAttribute('aria-label', `Move ${participant.name} up`);
+        moveUpButton.disabled = index === 0;
+        moveUpButton.addEventListener('click', () => {
+            moveParticipantInOrder(participant.id, -1);
+        });
+
+        const moveDownButton = document.createElement('button');
+        moveDownButton.type = 'button';
+        moveDownButton.className = 'participant-order-button';
+        moveDownButton.textContent = '↓';
+        moveDownButton.title = `Move ${participant.name} down`;
+        moveDownButton.setAttribute('aria-label', `Move ${participant.name} down`);
+        moveDownButton.disabled = index === participantsToRender.length - 1;
+        moveDownButton.addEventListener('click', () => {
+            moveParticipantInOrder(participant.id, 1);
+        });
+
+        orderControls.appendChild(moveUpButton);
+        orderControls.appendChild(moveDownButton);
+        row.appendChild(orderControls);
         participantsPanel.appendChild(row);
     }
 };
 
+let lastSAngle = null;
 const getParticipantsToRender = () => {
     const participantsToRender = participants.filter(p => p.id !== selfParticipant.id);
 
     // Add a synthetic North marker relative to the current self position for heading validation.
-    if (participantsToRender.findIndex(p => p.id === 'north-marker') === -1) {
+    if (participantsToRender.findIndex(p => p.id === NORTH_MARKER_ID) === -1) {
         participantsToRender.push({
-            id: 'north-marker',
+            id: NORTH_MARKER_ID,
             name: 'North',
             color: '#ffffff',
             position: {
@@ -590,7 +692,7 @@ const getParticipantsToRender = () => {
         lon: selfPosition.lon + stalkerDistance * Math.sin(stalkerAngle)
     };
     const testParticipant = {
-        id: 'test',
+        id: GUARDIAN_ANGEL_ID,
         name: 'Guardian Angel',
         color: '#ff0000',
         position: stalkerPosition,
@@ -598,11 +700,12 @@ const getParticipantsToRender = () => {
     };
     participantsToRender.push(testParticipant);
 
-    return participantsToRender;
+    return sortParticipantsForDisplay(participantsToRender);
 };
 
 const render = () => {
     const participantsToRender = getParticipantsToRender();
+    const content = document.getElementById('content');
 
     // 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.
@@ -627,10 +730,13 @@ const render = () => {
             arrowLabelAnchor.appendChild(arrowLabel);
             newArrow.appendChild(arrowLabelAnchor);
 
-            document.getElementById('content').appendChild(newArrow);
+            content.appendChild(newArrow);
             existingArrow = newArrow;
         }
 
+        // Keep DOM order in sync with participant order so stacking matches the list.
+        content.appendChild(existingArrow);
+
         if (!isParticipantVisible(target.id)) {
             existingArrow.style.display = 'none';
             continue;
@@ -668,7 +774,7 @@ const render = () => {
     document.body.style.backgroundColor = selfParticipant.color;
 
     // output debug info
-    logDebug(`Name: ${selfParticipant.name} | Position: ${selfParticipant.position.lat.toFixed(1)}, ${selfParticipant.position.lon.toFixed(1)} | Heading: ${selfHeading.toFixed(2)}° | Corr: ${headingCorrectionDeg.toFixed(1)}° | Real participants: ${participants.length} | Commit: ${GIT_COMMIT}`);
+    logDebug(`Name: ${selfParticipant.name} | Real participants: ${participants.length} | Commit: ${GIT_COMMIT}`);
 };
 
 await syncSelfParticipant();
diff --git a/src/style.css b/src/style.css
index 78de62c..c3cad48 100644
--- a/src/style.css
+++ b/src/style.css
@@ -206,7 +206,7 @@ a {
 
 .participant-row {
     display: grid;
-    grid-template-columns: auto 1fr;
+    grid-template-columns: auto 1fr auto;
     gap: 8px;
     align-items: center;
     margin-bottom: 6px;
@@ -241,6 +241,29 @@ a {
     text-decoration: none;
 }
 
+.participant-order-controls {
+    display: flex;
+    gap: 4px;
+}
+
+.participant-order-button {
+    width: 24px;
+    height: 24px;
+    border: 0;
+    border-radius: 6px;
+    background: rgba(255, 255, 255, 0.2);
+    color: #f8fafc;
+    font-size: 14px;
+    font-weight: 700;
+    line-height: 1;
+    cursor: pointer;
+}
+
+.participant-order-button:disabled {
+    opacity: 0.45;
+    cursor: default;
+}
+
 .arrow {
     position: absolute;
     top: 50%;
@@ -335,6 +358,17 @@ a {
     }
 
     .participant-row {
-        grid-template-columns: auto minmax(0, 1fr);
+        grid-template-columns: auto minmax(0, 1fr) auto;
+        gap: 6px;
+    }
+
+    .participant-order-controls {
+        gap: 3px;
+    }
+
+    .participant-order-button {
+        width: 22px;
+        height: 22px;
+        font-size: 12px;
     }
 }
\ No newline at end of file