ui changes - removed mobile features

This commit is contained in:
2026-06-15 02:37:06 +09:00
parent 65d5327a7b
commit 4c20bf5ab6
8 changed files with 131 additions and 466 deletions

View File

@@ -1,21 +1,21 @@
index.html,1781368618868,3d57a9fdc57223bc87524d1a469ee5d33b2d74717a5a2e5bd80af2a8e7bbea8f
_app/env.js,1781368618473,3ad3b33040ca005034c918e78bb90257fafb951a2ac1dcb9063c8b3c8af2f638
robots.txt,1780149196204,c42e958aa717ecf11e70c927ebe348b7cfdeb317d164463dde44a6245edc25bf
_app/immutable/nodes/3._gtiHFds.js,1781368617932,03f8d02b981f8b0e93287c16b1d0f68232b56de605e04e6e3064ac9559cc71b3
_app/version.json,1781368617944,42787a03af655ace3e2d902d2d107377fd378a90a2368f8275709993b4c87d7d
_app/immutable/entry/start.yteJby6e.js,1781368617927,b7bd03d31219b336864abd516345895c21693469ce71ca1ac89886507f46c357
_app/immutable/chunks/xihTtKlq.js,1781368617940,6dc2927621fce4ab1f29651cb0fc092849d26bcf1b4e7c03ca68912347351a5e
_app/immutable/nodes/1.BAF0rG-0.js,1781368617930,e6d2830148df3fcc69de7a81e6b2d769659f636a12abf16a81b95523c8b6a390
_app/immutable/nodes/0.CAyX_LgE.js,1781368617929,d1d85667126849483f6b48c8109bb5f24a48655d3611533f373588c76cd2bf95
_app/immutable/chunks/kNaey6uv.js,1781368617940,b2e326f189779bca5f499cc8b1019822fec2ee950002f2208b82d7574d863610
_app/immutable/entry/app.DCHY1y4u.js,1781368617926,fa7e6b6448286330f3d5755bc2b2415f99c9240f3a9b1c298fcb99483f360363
_app/immutable/chunks/dYw4s8FK.js,1781368617938,92bce86c6a19bb7e0519d637325d6b82f8da811e6574b4255feabff9bcdb90cb
_app/immutable/chunks/Da4iMNDa.js,1781368617937,bc7fe8acf9be416169b80d50ad0d021b0a540ed9c91ca752e96dcffdb47974d0
_app/immutable/assets/3.RINX6wbk.css,1781368617943,8f9a6eebf728dfc5907cb76bd37c871d2d1f334aca214eee962e83896b47578d
_app/immutable/chunks/BqvGMMFS.js,1781368617936,2c51af2452bfec64b3f17ba6d670db004801349cf42953bd1f81c8b40f258999
_app/immutable/chunks/1QO-eMGS.js,1781368617934,d377c0e4e478da49fbfc037b9a3cc1ce597ac2e88d99f81ac8218f111f82dcee
_app/immutable/assets/2.OyR8amje.css,1781368617943,c7a455732f6a961dd65e2dbe169fa612688277595ed10da3ba111c38a48ae110
_app/immutable/assets/0.DB_w-Orf.css,1781368617941,65816fda749a1c0cca0e0d336ab5ce2e8072226d1011391e0fa3577e8f8d7cd2
_app/immutable/chunks/D4ShdbHl.js,1781368617936,a5cf19027054fbc801c5e3c64857c30ba4a549222e534629580b035eb730efad
_app/immutable/nodes/2.CBoVBvZT.js,1781368617931,f5ae73581a7da49af51d4c97d6ef3b20566ad552f3ae9ff287df9b279a52adfd
_app/immutable/chunks/BSIi0fWd.js,1781368617935,81700d9f9acc30966a90b4a3305471b4deb9e1b0ef7b004283514a3a07bd80b6
index.html,1781458582579,c238f8f4335a07ba3975ee476ef3d9dc7029dbb4da2414bebea79539edb43ad8
_app/env.js,1781458582001,3ad3b33040ca005034c918e78bb90257fafb951a2ac1dcb9063c8b3c8af2f638
_app/immutable/nodes/1.DRGycYkH.js,1781458581476,ddc216c00274522f0163f6e17d7a61a5f83e3e464cc4db9a443d84028c562c60
_app/version.json,1781458581482,cbb552342fea01555352de88ca2249dc1c7967fe5631dc49cd83e85f37f1574b
_app/immutable/nodes/3.DuWWIOOm.js,1781458581477,26d857c375138b43445caa866442182f628215754f510953baf51b687ecc4262
_app/immutable/entry/start.BqO-PbzI.js,1781458581475,d82dbd922861c4a44043e136625387f1e0c3abe17c39221d0f5d2f704c27dc75
_app/immutable/chunks/xihTtKlq.js,1781458581481,6dc2927621fce4ab1f29651cb0fc092849d26bcf1b4e7c03ca68912347351a5e
_app/immutable/chunks/kNaey6uv.js,1781458581480,b2e326f189779bca5f499cc8b1019822fec2ee950002f2208b82d7574d863610
_app/immutable/entry/app.BKmqOLpp.js,1781458581474,8f657f3df0b7da5e2f354e4d0daaa527dd9154560deb2debb22be3a6d449cc1a
_app/immutable/chunks/dYw4s8FK.js,1781458581480,92bce86c6a19bb7e0519d637325d6b82f8da811e6574b4255feabff9bcdb90cb
_app/immutable/chunks/BqvGMMFS.js,1781458581477,2c51af2452bfec64b3f17ba6d670db004801349cf42953bd1f81c8b40f258999
_app/immutable/assets/2.B2G-eVjW.css,1781458581481,1c67ba19797be9f8fde6c067517d129820a6cf0d03c38e440bb6d9f9c83568a7
_app/immutable/assets/3.RINX6wbk.css,1781458581481,8f9a6eebf728dfc5907cb76bd37c871d2d1f334aca214eee962e83896b47578d
_app/immutable/assets/0.DB_w-Orf.css,1781458581481,65816fda749a1c0cca0e0d336ab5ce2e8072226d1011391e0fa3577e8f8d7cd2
_app/immutable/nodes/0.OUbaqqFA.js,1781458581475,d211915e16706bad6122ec7403c04f7e81e2db36ef347c39cc5dc9d54d67e6da
_app/immutable/chunks/DDp4mPSJ.js,1781458581478,02d46f2be4b0586a43d9f3a29ea25c0c44c22e444da9943f92b1fbb3f0ef4105
_app/immutable/chunks/Gly99FG6.js,1781458581480,b075ba40b4d6f384b98ffa0ab35b245b03b81877288e357fa3bfb31b24a41e54
_app/immutable/chunks/D4ShdbHl.js,1781458581478,a5cf19027054fbc801c5e3c64857c30ba4a549222e534629580b035eb730efad
_app/immutable/nodes/2.BnMN9e9a.js,1781458581476,4c5aeb7ba20a00990a6147dfa493edbe74409f93da27b40a312c423455c26013
_app/immutable/chunks/Dub02Wc7.js,1781458581478,b8f2be198d8c6cab67697e1420f2a9ed824cde281d9c998f1ce056daadd84bf1

View File

@@ -1,6 +1,6 @@
import { collection, query, where, getDocs, addDoc, getCountFromServer } from 'firebase/firestore'; // tools for building and running db queries
import { db, auth } from './config'; // database connection + anonymous auth
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
import { getNearbyGeohashCells } from '$lib/utils/geohash'; // convert coordinates into a 3x3 grid of geohash cell prefixes (handles cell-boundary edge cases - see geohash.js)
import { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
import ngeohash from 'ngeohash';
@@ -16,17 +16,31 @@ import ngeohash from 'ngeohash';
const statsRef = doc(db, 'meta', 'stats');
export async function getNearbyMessages(lat, lng) {
const prefix = getQueryPrefix(lat, lng);
// query the user's geohash cell AND its 8 neighbors (3x3 grid) rather
// than a single prefix range - see getNearbyGeohashCells in geohash.js
// for why a single cell isn't reliable enough across devices.
const cells = getNearbyGeohashCells(lat, lng);
// filter by the geohash
const q = query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
const snapshots = await Promise.all(
cells.map(prefix => getDocs(query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
)))
);
const snapshot = await getDocs(q);
const all = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
// merge the 9 result sets into one list. Neighboring geohash cells never
// overlap, so each message can only appear in one snapshot - but we
// dedupe by doc id anyway as a defensive measure, in case that ever changes.
const seen = new Map();
for (const snapshot of snapshots) {
for (const docSnap of snapshot.docs) {
if (!seen.has(docSnap.id)) {
seen.set(docSnap.id, { id: docSnap.id, ...docSnap.data() });
}
}
}
const all = [...seen.values()];
// we filter out messages which have already expired (past their echo date)
const now = Date.now();
@@ -55,16 +69,19 @@ export async function getNearbyMessages(lat, lng) {
// the query. Returns true if at least one message (active or expired) has
// ever existed in this area.
export async function hasAnyMessagesEverNearby(lat, lng) {
const prefix = getQueryPrefix(lat, lng);
// same 3x3 cell grid as getNearbyMessages, for the same boundary reason -
// neighboring cells never overlap, so the counts can simply be summed.
const cells = getNearbyGeohashCells(lat, lng);
const q = query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
const counts = await Promise.all(
cells.map(prefix => getCountFromServer(query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
)))
);
const snapshot = await getCountFromServer(q);
return snapshot.data().count > 0;
return counts.some(snapshot => snapshot.data().count > 0);
}
// update the echo counter

View File

@@ -6,7 +6,6 @@
import { messagePin, locationPin } from '$lib/utils/pins.js'; // custom SVG pin generators
import { mapStyles } from '$lib/utils/mapStyles.js'; // desaturated/atmospheric map styling - only works now that mapId is gone
import { getTrail, addTrailPoint } from '$lib/utils/trail.js'; // private, on-device-only location history
import { distanceMeters, bearingDegrees } from '$lib/utils/geo.js'; // great-circle distance/bearing for "sensing mode"
// export let latitude;
// export let longitude;
@@ -14,9 +13,7 @@
// firstHereMounted/firstHereVisible: "you're the first here" bubble state,
// computed in +page.svelte (zero-results check + fade timing) - rendered
// here so it can be positioned near the user's location marker below.
// sensingMode: combined compass-arrow + proximity-vibration mode, toggled
// by SensingToggle in +page.svelte (mobile-only - see the effect below).
let { lat, lng, firstHereMounted = false, firstHereVisible = false, sensingMode = false } = $props();
let { lat, lng, firstHereMounted = false, firstHereVisible = false } = $props();
let mapDiv;
let map = $state(null);
@@ -46,39 +43,6 @@
// the toggle just lets them hide it if they'd rather not see it.
let showTrail = $state(true);
// --- "sensing mode": compass arrow + proximity vibration ---------------
// `heading` is the device's current compass heading in degrees (0 = true
// north, clockwise), updated by the deviceorientation listener below.
// Defaults to 0 ("facing north") rather than null: devices/browsers
// without an orientation sensor (e.g. desktop) never fire that event, so
// heading would otherwise stay null forever and the arrow would never
// appear at all. With a 0 default, the arrow instead falls back to a
// "north-up" compass - pointing at the absolute bearing toward the
// target - and starts rotating relative to the device's actual facing
// direction the moment real orientation data arrives.
// `nearestOffScreenBearing` is the compass bearing (also 0-360, 0 = true
// north) from the user's current position to the nearest message pin -
// recomputed on the interval below. `null` only when there's no nearby
// message at all (or the map isn't ready yet), in which case the arrow
// stays hidden - there's nothing to point at.
let heading = $state(0);
let nearestOffScreenBearing = $state(null);
// The arrow needs to point at `nearestOffScreenBearing` RELATIVE TO the
// device's current facing direction - e.g. if the pin is due north
// (bearing 0) and the device is currently facing east (heading 90), the
// arrow should point 90deg counter-clockwise (-90 / 270) from "up" on
// the screen, since "up" on the screen = wherever the device is facing.
// Subtracting heading from the target bearing gives exactly that (with
// heading's default of 0, "up" simply means "true north" until real
// orientation data arrives). `null` (nothing to point at) hides the
// arrow entirely.
let arrowRotation = $derived(
nearestOffScreenBearing !== null
? (nearestOffScreenBearing - heading + 360) % 360
: null
);
// converts a { url, size } pin (from pins.js) into an Icon object for
// the legacy Marker API - centers the icon on its coordinate (anchor at
// size/2, size/2) the same way the old AdvancedMarkerElement content's
@@ -243,167 +207,6 @@
}
})
// --- sensing mode: nearest off-screen pin ------------------------------
// Looks at every currently-loaded nearby message and finds both (a) the
// closest one that's currently outside the map's visible bounds, and (b)
// the closest one overall. Sets nearestOffScreenBearing to the bearing
// toward (a) if one exists, otherwise falls back to (b).
// The fallback matters because getNearbyMessages's ~1.2km geohash area is
// roughly the same size as the map's default viewport - in the common
// case EVERY nearby message is already on-screen, so "off-screen only"
// would mean nearestOffScreenBearing (and therefore the arrow) is null
// almost all the time. Preferring an off-screen pin (pointing toward
// something not already visible is more useful) but falling back to the
// nearest pin overall means the arrow still has something to point at,
// instead of never appearing. Stays null only if there are no nearby
// messages at all (or the map/bounds aren't ready yet) - either way, the
// arrow hides itself via arrowRotation above.
function updateNearestOffScreenPin(userLat, userLng) {
const bounds = map?.getBounds();
let nearestOffScreen = null;
let nearestOffScreenDist = Infinity;
let nearestOverall = null;
let nearestOverallDist = Infinity;
for (const message of $messagesStore) {
const dist = distanceMeters(userLat, userLng, message.lat, message.lng);
if (dist < nearestOverallDist) {
nearestOverallDist = dist;
nearestOverall = message;
}
const onScreen = bounds?.contains({ lat: message.lat, lng: message.lng }) ?? false;
if (!onScreen && dist < nearestOffScreenDist) {
nearestOffScreenDist = dist;
nearestOffScreen = message;
}
}
const target = nearestOffScreen ?? nearestOverall;
nearestOffScreenBearing = target
? bearingDegrees(userLat, userLng, target.lat, target.lng)
: null;
}
// --- sensing mode: proximity vibration ----------------------------------
// Checks every nearby message against (userLat, userLng) and, for any pin
// within PROXIMITY_METERS that this session hasn't already buzzed for,
// fires a single soft pulse. `vibratedIds` is a plain (non-reactive) Set -
// it only needs to track membership for this check, not drive any UI -
// and is recreated each time sensing mode is turned on (see the effect
// below), so re-enabling the toggle lets the same pin buzz again.
const PROXIMITY_METERS = 50;
let vibratedIds = new Set();
function checkProximityVibration(userLat, userLng) {
// gracefully do nothing on browsers without the Vibration API
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') return;
for (const message of $messagesStore) {
if (vibratedIds.has(message.id)) continue;
const dist = distanceMeters(userLat, userLng, message.lat, message.lng);
if (dist <= PROXIMITY_METERS) {
navigator.vibrate(100); // single soft pulse
vibratedIds.add(message.id);
}
}
}
// --- sensing mode: lifecycle --------------------------------------------
// Runs whenever `sensingMode` changes (toggled by SensingToggle in
// +page.svelte). While ON, this sets up everything the two features need
// and tears it all down again the moment it's turned OFF or this
// component unmounts - no listeners/timers/watchers should outlive the
// toggle being on.
$effect(() => {
if (!sensingMode) return;
// fresh "already buzzed" set each time sensing mode is (re-)enabled
vibratedIds = new Set();
// Live position for sensing: separate from the `lat`/`lng` props
// (which only reflect the one-time fix used to center the map) -
// watchPosition keeps these updated as the user actually walks
// around, which is what makes "proximity" and "nearest off-screen
// pin" meaningful. Starts from the map's initial fix so there's a
// usable value immediately, before the first watch update arrives.
let userLat = Number(lat);
let userLng = Number(lng);
// --- DeviceOrientationEvent: device compass heading -----------------
// iOS exposes a ready-made compass heading via webkitCompassHeading
// (0 = true north, increases clockwise). Other browsers only provide
// `alpha` (rotation around the z-axis, increasing COUNTER-clockwise
// from north when the device is lying flat) - `360 - alpha` converts
// that into the same "clockwise from north" convention. This is an
// approximation (it assumes the device is held flat/upright facing
// the user) but is good enough for a rough directional arrow.
function handleOrientation(event) {
if (event.webkitCompassHeading != null) {
heading = event.webkitCompassHeading;
} else if (event.alpha != null) {
heading = (360 - event.alpha) % 360;
}
}
if (typeof window !== 'undefined' && typeof DeviceOrientationEvent !== 'undefined') {
window.addEventListener('deviceorientation', handleOrientation);
// Chrome on Android only reports a true compass heading via
// 'deviceorientationabsolute' - plain 'deviceorientation' events
// there have `absolute: false` and `alpha` relative to whatever
// direction the device happened to be facing when it booted, not
// true north, which made the arrow point in an arbitrary (and
// sometimes never-updating) direction. iOS doesn't dispatch this
// event at all, so it just never fires there and
// webkitCompassHeading (above) continues to be used.
window.addEventListener('deviceorientationabsolute', handleOrientation);
}
// if DeviceOrientationEvent doesn't exist at all, heading just stays
// null forever - arrowRotation stays null and the arrow stays hidden,
// which is the "handle unavailable API gracefully" behavior.
// --- periodic sensing check ------------------------------------------
// every few seconds, recompute the nearest off-screen pin (for the
// arrow) and run the proximity-vibration check, using the latest
// watched position.
function runSensingCheck() {
updateNearestOffScreenPin(userLat, userLng);
checkProximityVibration(userLat, userLng);
}
runSensingCheck();
const intervalId = setInterval(runSensingCheck, 3000);
// keep userLat/userLng current as the device moves
let watchId = null;
if (typeof navigator !== 'undefined' && navigator.geolocation?.watchPosition) {
watchId = navigator.geolocation.watchPosition(
(position) => {
userLat = position.coords.latitude;
userLng = position.coords.longitude;
},
() => {}, // ignore watch errors - sensing just keeps using the last-known position
{ enableHighAccuracy: true, maximumAge: 5000 }
);
}
// cleanup: tear everything down the moment sensing mode is turned off
// (or this component is destroyed) - no lingering listeners/timers.
return () => {
window.removeEventListener('deviceorientation', handleOrientation);
window.removeEventListener('deviceorientationabsolute', handleOrientation);
clearInterval(intervalId);
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
}
heading = 0; // back to the "facing north" default (see declaration above)
nearestOffScreenBearing = null;
};
});
</script>
<div class="map-wrapper">
@@ -433,25 +236,6 @@
no one has been here before — or at least, no one who said so.
</p>
{/if}
<!-- "sensing mode" compass arrow: only rendered while sensing mode is on
AND there's a heading + a nearby message to point at (arrowRotation
is null otherwise - see the $derived above and
updateNearestOffScreenPin's fallback). Bottom-left placement
keeps it clear of .trail-toggle (top-left) and the FAB/SensingToggle
(bottom-right, in +page.svelte). The inner <svg> is what actually
rotates, via arrowRotation.
Also hidden while a message is open (BottomSheet/SidePanel showing
selectedMessage) - the arrow's bottom-left position overlaps that
reading view, and pointing toward another pin isn't useful while
you're in the middle of reading one. -->
{#if sensingMode && arrowRotation !== null && !$mapStore.selectedMessage}
<div class="compass-arrow" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 20 20" style="transform: rotate({arrowRotation}deg)">
<path d="M10 1 L16 15 L10 11.5 L4 15 Z" fill="#9c8ad6" />
</svg>
</div>
{/if}
</div>
<style>
@@ -472,31 +256,6 @@
height: 100%;
}
/* "sensing mode" compass arrow - small round pastel "puck", bottom-left
of the map (clear of .trail-toggle at top-left and the FAB/
SensingToggle at bottom-right in +page.svelte). bottom offset clears
the mobile bottom nav (64px) the same way .fab's 5rem does. The arrow
<svg> inside is rotated via arrowRotation (see <script>); the puck
itself never rotates. */
.compass-arrow {
position: absolute;
bottom: 5rem;
left: 1rem;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
z-index: 120;
}
.compass-arrow svg {
transition: transform 0.15s linear;
}
/* small circular toggle, top-left of the map - flat 2D style (no
shadow) matching the rest of the app's current look. Muted/translucent
so it doesn't draw attention away from the map itself; .active just

View File

@@ -1,63 +0,0 @@
<script>
// On/off control for "sensing mode" (compass arrow + proximity vibration,
// see MapView.svelte). `checked` is owned by the parent (+page.svelte) -
// this component is just the button and reports taps via `onchange`,
// rather than holding its own state, so the parent can do the iOS
// permission request (which must happen inside this same click handler -
// see +page.svelte) before actually flipping the value.
let { checked = false, onchange } = $props();
</script>
<!-- same small round icon-button style as MapView's .trail-toggle - .active
just shifts it from muted grey to the app's darker text color, matching
how .trail-toggle.active works -->
<button
type="button"
class="sensing-toggle"
class:active={checked}
aria-label={checked ? 'Turn off sensing mode' : 'Turn on sensing mode'}
title={checked ? 'Turn off sensing mode' : 'Turn on sensing mode'}
onclick={() => onchange?.(!checked)}
>
<!-- compass icon: outer ring + a needle pointing toward an off-screen
pin, echoing the compass-arrow feature this toggle controls -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6.5"/>
<path d="M10.3 5.7 L8.6 8.6 L5.7 10.3 L7.4 7.4 Z" fill="currentColor" stroke-width="0"/>
</svg>
</button>
<style>
/* directly above the FAB (mobile-only - this component is only rendered
when isMobile, see +page.svelte) - same fixed-position math as
.sensing-toggle-wrap used previously: FAB's own bottom offset (5rem) +
FAB's height (56px) + a small gap. */
.sensing-toggle {
position: fixed;
bottom: calc(5rem + 56px + 0.75rem);
right: 1.5rem;
z-index: 150;
/* same small circular icon-button look as MapView's .trail-toggle -
flat 2D style (no shadow), muted/translucent so it doesn't compete
with the FAB below it. */
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.7);
color: #aaa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: color 0.15s, border-color 0.15s;
}
/* "on" state: same darken-on-active treatment as .trail-toggle.active */
.sensing-toggle.active {
color: #666;
border-color: rgba(0, 0, 0, 0.15);
}
</style>

View File

@@ -115,42 +115,48 @@
<!-- detail view: shown for the selected message -->
<button class="back-btn" onclick={close}> Back</button>
<div class="detail-content" class:last-chance={isLastChance}>
{#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if}
<p class="message-text">{message.text}</p>
{#if decay}
{#if isLastChance}
<!-- "last chance" framing replaces the normal countdown text -->
<p class="meta last-chance-text">this is the last chance to keep this alive</p>
{:else}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
<!-- wraps both the message content and the action buttons, so the
"last chance" gold/cream container can extend to include the
buttons too - mirrors BottomSheet.svelte's .content wrapper, which
already does this, for visual consistency between mobile and desktop -->
<div class="detail-wrap" class:last-chance={isLastChance}>
<div class="detail-content">
{#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if}
{/if}
{#if showLastChancePrompt}
<!-- one-time prompt (see stats.js) - gently frames Echo as the way
to give this message more time, without being pushy. Dismissible
on its own, or implicitly dismissed by pressing Echo/Let go below. -->
<div class="last-chance-prompt">
<p>if this one means something, an echo will keep it here a little longer.</p>
<button class="dismiss-prompt" onclick={() => showLastChancePrompt = false} aria-label="dismiss">×</button>
</div>
{/if}
</div>
<p class="message-text">{message.text}</p>
<div class="actions">
<button class="echo-button" onclick={handleEcho}>
Echo
</button>
<button class="letgo-button" onclick={handleLetGo}>
Let go
</button>
<!-- share link + QR code popover (see SharePopover.svelte) -->
<SharePopover {message} />
{#if decay}
{#if isLastChance}
<!-- "last chance" framing replaces the normal countdown text -->
<p class="meta last-chance-text">this is the last chance to keep this alive</p>
{:else}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
{/if}
{/if}
{#if showLastChancePrompt}
<!-- one-time prompt (see stats.js) - gently frames Echo as the way
to give this message more time, without being pushy. Dismissible
on its own, or implicitly dismissed by pressing Echo/Let go below. -->
<div class="last-chance-prompt">
<p>if this one means something, an echo will keep it here a little longer.</p>
<button class="dismiss-prompt" onclick={() => showLastChancePrompt = false} aria-label="dismiss">×</button>
</div>
{/if}
</div>
<div class="actions">
<button class="echo-button" onclick={handleEcho}>
Echo
</button>
<button class="letgo-button" onclick={handleLetGo}>
Let go
</button>
<!-- share link + QR code popover (see SharePopover.svelte) -->
<SharePopover {message} />
</div>
</div>
{:else}
<!-- list view: header, compose button, nearby messages -->
@@ -282,11 +288,23 @@
color: #999;
}
/* wraps .detail-content and .actions together - lets the "last chance"
background below extend around the action buttons too, not just the
message text. flex: 1 fills the remaining height of .panel (taking over
the role .actions's margin-top: auto used to play directly inside
.panel), so .actions (still margin-top: auto, now relative to this
wrapper) stays pinned to the bottom either way. */
.detail-wrap {
display: flex;
flex-direction: column;
flex: 1;
}
/* "last chance" treatment - a soft warm peach tint + border, distinct from
the normal panel background but staying within the existing pastel/
parchment palette. A faint glow (box-shadow) adds a touch of warmth
without being a jarring "alert" color like red. */
.detail-content.last-chance {
.detail-wrap.last-chance {
background: #fff6ea;
border: 1px solid #f3dcb8;
border-radius: 14px;
@@ -302,7 +320,7 @@
}
/* one-time prompt shown above the action buttons - same warm palette as
.detail-content.last-chance, slightly more saturated so it reads as the
.detail-wrap.last-chance, slightly more saturated so it reads as the
"active" element within the card */
.last-chance-prompt {
display: flex;

View File

@@ -1,43 +0,0 @@
// --- geo utilities for "sensing mode" (compass arrow + proximity vibration) -
// Both formulas are standard great-circle formulas. They're approximations
// (Earth isn't a perfect sphere), but for the distances this app cares about
// - "is this pin within ~50m" and "which direction is it in" within a single
// ~1.2km neighborhood - that error is negligible.
const EARTH_RADIUS_M = 6371000; // mean Earth radius, in meters
function toRad(deg) {
return (deg * Math.PI) / 180;
}
function toDeg(rad) {
return (rad * 180) / Math.PI;
}
// Haversine formula: great-circle distance between two lat/lng points, in
// meters. Used by the proximity-vibration check to find pins within ~50m.
export function distanceMeters(lat1, lng1, lat2, lng2) {
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// Initial bearing (compass direction in degrees, 0-360, 0 = true north,
// clockwise) from point 1 to point 2. Used by the compass arrow to figure
// out which way the nearest off-screen pin is, before adjusting for the
// device's own heading.
export function bearingDegrees(lat1, lng1, lat2, lng2) {
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dLng = toRad(lng2 - lng1);
const y = Math.sin(dLng) * Math.cos(phi2);
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLng);
return (toDeg(Math.atan2(y, x)) + 360) % 360;
}

View File

@@ -17,4 +17,26 @@ export function encode(lat, lng) {
// stored on existing messages (see encode() above)
export function getQueryPrefix(lat, lng) {
return ngeohash.encode(lat, lng, 6);
}
// --- geohash cell-boundary problem ----------------------------------------
// A single precision-6 geohash cell is only ~1.2km x 0.6km. Two devices
// standing in the same real-world spot can still land in DIFFERENT cells if
// their reported coordinates fall on opposite sides of a cell edge - this is
// very common, since desktop browsers usually resolve location via WiFi/IP
// (often off by hundreds of meters) while phones use GPS (much tighter).
// Querying only getQueryPrefix(lat, lng)'s single cell means one device's
// query can miss messages that are physically just a few meters away, on the
// other side of that edge - which is exactly the "different pin counts on
// different devices" symptom.
//
// getNearbyGeohashCells returns the user's cell PLUS its 8 surrounding cells
// (a 3x3 grid, via ngeohash.neighbors). Querying all 9 means that as long as
// both devices' reported positions are within roughly one cell-width of each
// other (which covers normal GPS/WiFi error), their 3x3 grids overlap enough
// to cover the same messages - so both devices see the same "nearby" set
// even if they're each centered in a different cell.
export function getNearbyGeohashCells(lat, lng) {
const center = getQueryPrefix(lat, lng);
return [center, ...ngeohash.neighbors(center)];
}

View File

@@ -1,7 +1,6 @@
<script>
import { onMount } from 'svelte';
import MapView from '$lib/components/MapView.svelte';
import SensingToggle from '$lib/components/SensingToggle.svelte';
import { getNearbyMessages, hasAnyMessagesEverNearby, getMessageById } from '$lib/firebase/messages.js';
import { getDecayInfo } from '$lib/utils/time.js'; // checks whether a shared message has expired
@@ -34,37 +33,6 @@
let firstHereMounted = $state(false);
let firstHereVisible = $state(false);
// --- "sensing mode" (compass arrow + proximity vibration) ---------------
// Single combined toggle for both device-sensor features (see
// SensingToggle + MapView.svelte). Defaults to OFF: these features use
// device sensors (orientation, vibration, continuous location) more
// actively than the rest of the app, so they're an opt-in "mode" rather
// than always-on.
let sensingMode = $state(false);
// Turning the toggle ON is itself a user-interaction "click", which is
// exactly the context iOS Safari requires for
// DeviceOrientationEvent.requestPermission() - it must be called directly
// from a user gesture, so it has to happen here (in the toggle's click
// handler) rather than later inside MapView's effect. If the API doesn't
// exist at all (non-iOS), there's nothing to request - sensingMode just
// flips on and MapView's deviceorientation listener works without a
// permission prompt. If the user denies the prompt, sensingMode still
// turns on (proximity vibration doesn't need orientation), but no
// deviceorientation events will arrive, so the compass arrow simply never
// appears (arrowRotation stays null) - handled gracefully, no error shown.
async function handleSensingToggle(newValue) {
if (newValue && typeof DeviceOrientationEvent !== 'undefined'
&& typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
await DeviceOrientationEvent.requestPermission();
} catch {
// ignore - arrow just won't appear without orientation data
}
}
sensingMode = newValue;
}
onMount(() => {
// sign this device in anonymously so messages can be linked to a persistent UID
initAuth();
@@ -273,24 +241,11 @@
<!-- firstHereMounted/firstHereVisible (state + fade timing computed
in onMount/$effects above) are passed down so MapView can render
the "you're the first here" bubble positioned near the user's
location marker, rather than floating independently of the map.
sensingMode (toggled by SensingToggle below) drives MapView's
compass arrow + proximity vibration. -->
<MapView {lat} {lng} {firstHereMounted} {firstHereVisible} {sensingMode} />
location marker, rather than floating independently of the map. -->
<MapView {lat} {lng} {firstHereMounted} {firstHereVisible} />
</div>
{/if}
<!-- "sensing mode" toggle: mobile-only (physical-world features don't make
sense on desktop), placed directly above the FAB so the two read as a
stack of floating controls in the bottom-right corner (positioning is
handled inside SensingToggle.svelte itself, like .trail-toggle in
MapView.svelte). Hidden whenever a message is open, same as the FAB
below - it sits right above the FAB, so it overlapped the open message
view (BottomSheet/SidePanel) the same way the FAB would have. -->
{#if isMobile && !$mapStore.selectedMessage}
<SensingToggle checked={sensingMode} onchange={handleSensingToggle} />
{/if}
<!-- show the right panel based on mobile or desktop-->
{#if windowWidth < 768}
<BottomSheet message={$mapStore.selectedMessage} />