ui changes - removed mobile features
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user