feat(hunt): track walking distance via GPS, show live locations on map
Add foreground-only GPS distance tracking for hunts
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type TrackableHunt,
|
||||
useGeoTracking
|
||||
} from '~/composables/useGeoTracking';
|
||||
|
||||
const props = defineProps<{
|
||||
hunt: TrackableHunt;
|
||||
seedDistance?: number;
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { formatDistance } = useFormatDistance();
|
||||
|
||||
const {
|
||||
distance,
|
||||
lastFix,
|
||||
paused,
|
||||
consented,
|
||||
eligible,
|
||||
gate,
|
||||
isSupported,
|
||||
start,
|
||||
stop,
|
||||
toggleConsent
|
||||
} = useGeoTracking(props.hunt, props.seedDistance ?? 0);
|
||||
|
||||
// Ticking clock so the "last fix … ago" label stays live while the card is open.
|
||||
const nowTs = ref(Date.now());
|
||||
let clock: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
clock = setInterval(() => (nowTs.value = Date.now()), 1000);
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
if (clock !== undefined) clearInterval(clock);
|
||||
});
|
||||
|
||||
const lastFixAgo = computed(() => {
|
||||
if (!lastFix.value) return '';
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.round((nowTs.value - lastFix.value.getTime()) / 1000)
|
||||
);
|
||||
return new Intl.RelativeTimeFormat(locale.value || 'en', {
|
||||
numeric: 'auto'
|
||||
}).format(-seconds, 'second');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VaCard
|
||||
v-if="eligible"
|
||||
stripe
|
||||
:stripe-color="gate && !paused ? 'success' : 'primary'"
|
||||
>
|
||||
<VaCardTitle>{{ t('page.hunt.tracking.title') }}</VaCardTitle>
|
||||
<VaCardContent>
|
||||
<div class="flex flex-col gap-3">
|
||||
<VaAlert v-if="!isSupported" color="warning">
|
||||
{{ t('page.hunt.tracking.unsupported') }}
|
||||
</VaAlert>
|
||||
<VaSwitch
|
||||
:model-value="consented"
|
||||
:disabled="!isSupported"
|
||||
:label="t('page.hunt.tracking.consent')"
|
||||
@update:model-value="toggleConsent"
|
||||
/>
|
||||
<p class="text-sm opacity-70">
|
||||
{{ t('page.hunt.tracking.consent_explain') }}
|
||||
</p>
|
||||
<template v-if="consented">
|
||||
<div class="grid grid-cols-2 gap-2 text-lg md:grid-cols-3">
|
||||
<VaChip :color="paused ? 'secondary' : 'success'">
|
||||
{{
|
||||
t(
|
||||
paused
|
||||
? 'page.hunt.tracking.status_paused'
|
||||
: 'page.hunt.tracking.status_tracking'
|
||||
)
|
||||
}}
|
||||
</VaChip>
|
||||
<span class="font-bold">{{ formatDistance(distance) }}</span>
|
||||
<span
|
||||
v-if="lastFixAgo"
|
||||
class="col-span-2 text-sm opacity-70 md:col-span-1"
|
||||
>
|
||||
{{ t('page.hunt.tracking.last_fix', { ago: lastFixAgo }) }}
|
||||
</span>
|
||||
</div>
|
||||
<VaButton
|
||||
:disabled="!isSupported"
|
||||
:icon="paused ? 'play_arrow' : 'pause'"
|
||||
@click="paused ? start() : stop()"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
paused
|
||||
? 'page.hunt.tracking.resume'
|
||||
: 'page.hunt.tracking.pause'
|
||||
)
|
||||
}}
|
||||
</VaButton>
|
||||
</template>
|
||||
</div>
|
||||
</VaCardContent>
|
||||
</VaCard>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { fullDate } from '~~/server/utils/date';
|
||||
const { confirm } = useModal();
|
||||
const { init } = useToast();
|
||||
const { t, d } = useI18n();
|
||||
const { formatDistance } = useFormatDistance();
|
||||
|
||||
const props = defineProps<{
|
||||
team: Team;
|
||||
@@ -151,7 +152,7 @@ async function deleteTeam() {
|
||||
<VaCollapse
|
||||
:key="team.id"
|
||||
color="info"
|
||||
:header="`${team.name} — ${team.members.length} members — ${team._count.answers} answers — ${team.score} points`"
|
||||
:header="`${team.name} — ${team.members.length} members — ${team._count.answers} answers — ${team.score} points — ${formatDistance(team.distance)}`"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-xl">
|
||||
@@ -171,6 +172,9 @@ async function deleteTeam() {
|
||||
by {{ team.owner.name }} ({{ team.owner.email }})
|
||||
</p>
|
||||
<p>{{ team._count.answers }} answers</p>
|
||||
<p v-if="team.distance > 0">
|
||||
{{ t('page.common.distance') }}: {{ formatDistance(team.distance) }}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
<VaButton
|
||||
v-for="member in team.members"
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import type { HuntMapData } from '#shared/types/map';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useI18nKey } from '~/composables/useI18nKey';
|
||||
import { darken } from '~/composables/useTeamColors';
|
||||
import { fullDate } from '~~/server/utils/date';
|
||||
|
||||
const props = defineProps<{
|
||||
huntId: number;
|
||||
huntSlug: string;
|
||||
data: HuntMapData;
|
||||
colors: Map<number, string>;
|
||||
showAnswers: boolean;
|
||||
showPositions: boolean;
|
||||
selectedUserId: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-user', userId: number | null): void;
|
||||
}>();
|
||||
|
||||
const { t, d, locale } = useI18n();
|
||||
const { tSluggy } = useI18nKey();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
let tile: L.TileLayer | null = null;
|
||||
let answerLayer: L.LayerGroup | null = null;
|
||||
let positionLayer: L.LayerGroup | null = null;
|
||||
let trailLayer: L.LayerGroup | null = null;
|
||||
let fitted = false;
|
||||
|
||||
// Lookup maps rebuilt whenever data changes.
|
||||
const teamName = computed(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const team of props.data.teams) m.set(team.id, team.name);
|
||||
return m;
|
||||
});
|
||||
const userName = computed(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const team of props.data.teams) {
|
||||
m.set(team.owner.id, team.owner.name);
|
||||
for (const mem of team.members) m.set(mem.id, mem.name);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
const userTeam = computed(() => {
|
||||
const m = new Map<number, number>();
|
||||
for (const team of props.data.teams) {
|
||||
m.set(team.owner.id, team.id);
|
||||
for (const mem of team.members) m.set(mem.id, team.id);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
function colorForTeam(teamId: number): string {
|
||||
return props.colors.get(teamId) ?? '#808080';
|
||||
}
|
||||
|
||||
function pinIcon(color: string): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<svg width="28" height="40" viewBox="0 0 28 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 0C6.3 0 0 6.3 0 14c0 10.5 14 26 14 26s14-15.5 14-26C28 6.3 21.7 0 14 0z" fill="${color}" stroke="#fff" stroke-width="1.5"/>
|
||||
<circle cx="14" cy="14" r="5" fill="#fff"/>
|
||||
</svg>`,
|
||||
iconSize: [28, 40],
|
||||
iconAnchor: [14, 40],
|
||||
popupAnchor: [0, -38]
|
||||
});
|
||||
}
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
);
|
||||
return new Intl.RelativeTimeFormat(locale.value || 'en', {
|
||||
numeric: 'auto'
|
||||
}).format(-seconds, 'second');
|
||||
}
|
||||
|
||||
function questAdminHref(quest: {
|
||||
id: number;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
}) {
|
||||
return localePath(`/hunt/${props.huntSlug}/${tSluggy(quest).value}/admin`);
|
||||
}
|
||||
|
||||
function answerPopup(a: HuntMapData['answers'][number]): string {
|
||||
const title =
|
||||
locale.value === 'en'
|
||||
? a.quest.title_en
|
||||
: (a.quest.title_de ?? a.quest.title_en);
|
||||
const team = teamName.value.get(a.teamId) ?? '';
|
||||
const member = userName.value.get(a.memberId) ?? '';
|
||||
const time = d(new Date(a.createdAt), fullDate);
|
||||
const thumb = a.pictureUrl
|
||||
? `<img src="${a.pictureUrl}" alt="" style="max-width:160px;max-height:120px;border-radius:4px;margin:4px 0;" />`
|
||||
: '';
|
||||
return `<div style="min-width:180px">
|
||||
<strong>${title}</strong><br/>
|
||||
<span>${t('page.map.team')}: ${team}</span><br/>
|
||||
<span>${t('page.map.member')}: ${member}</span><br/>
|
||||
<span>${t('page.map.time')}: ${time}</span>
|
||||
${thumb}
|
||||
<a href="${questAdminHref(a.quest)}" target="_blank">${t('page.map.open_quest_admin')}</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function positionPopup(p: HuntMapData['positions'][number]): string {
|
||||
const teamId = userTeam.value.get(p.userId);
|
||||
const team = teamId != null ? (teamName.value.get(teamId) ?? '') : '';
|
||||
const name = userName.value.get(p.userId) ?? '';
|
||||
const acc =
|
||||
p.accuracy != null
|
||||
? `<br/><span>${t('page.map.accuracy')}: ${Math.round(p.accuracy)} m</span>`
|
||||
: '';
|
||||
return `<div style="min-width:160px">
|
||||
<strong>${name}</strong><br/>
|
||||
<span>${t('page.map.team')}: ${team}</span><br/>
|
||||
<span>${t('page.map.last_fix', { ago: relTime(p.recordedAt) })}</span>${acc}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAnswers() {
|
||||
if (!map || !answerLayer) return;
|
||||
answerLayer.clearLayers();
|
||||
if (!props.showAnswers) return;
|
||||
for (const a of props.data.answers) {
|
||||
const marker = L.marker([a.lat, a.lon], {
|
||||
icon: pinIcon(colorForTeam(a.teamId))
|
||||
}).bindPopup(answerPopup(a));
|
||||
answerLayer.addLayer(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPositions() {
|
||||
if (!map || !positionLayer) return;
|
||||
positionLayer.clearLayers();
|
||||
if (!props.showPositions) return;
|
||||
for (const p of props.data.positions) {
|
||||
const teamId = userTeam.value.get(p.userId);
|
||||
const color = teamId != null ? colorForTeam(teamId) : '#808080';
|
||||
const dot = L.circleMarker([p.lat, p.lon], {
|
||||
radius: 7,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 1
|
||||
}).bindPopup(positionPopup(p));
|
||||
dot.on('click', () => {
|
||||
emit('select-user', props.selectedUserId === p.userId ? null : p.userId);
|
||||
});
|
||||
positionLayer.addLayer(dot);
|
||||
}
|
||||
}
|
||||
|
||||
function fitOnce() {
|
||||
if (!map || fitted) return;
|
||||
const pts: L.LatLngExpression[] = [
|
||||
...(props.showAnswers
|
||||
? props.data.answers.map((a) => [a.lat, a.lon] as L.LatLngExpression)
|
||||
: []),
|
||||
...(props.showPositions
|
||||
? props.data.positions.map((p) => [p.lat, p.lon] as L.LatLngExpression)
|
||||
: [])
|
||||
];
|
||||
if (pts.length === 0) return;
|
||||
const bounds = L.latLngBounds(pts);
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 17 });
|
||||
fitted = true;
|
||||
}
|
||||
|
||||
async function renderTrail() {
|
||||
if (!map || !trailLayer) return;
|
||||
trailLayer.clearLayers();
|
||||
const uid = props.selectedUserId;
|
||||
if (uid == null) return;
|
||||
|
||||
const { points } = await $fetch<{
|
||||
points: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracy: number | null;
|
||||
recordedAt: string;
|
||||
}[];
|
||||
}>(`/api/admin/hunt/${props.huntId}/track/${uid}`);
|
||||
|
||||
if (points.length === 0) return;
|
||||
|
||||
const teamId = userTeam.value.get(uid);
|
||||
const base = teamId != null ? colorForTeam(teamId) : '#808080';
|
||||
const latlngs = points.map((p) => [p.lat, p.lon] as L.LatLngExpression);
|
||||
|
||||
const line = L.polyline(latlngs, {
|
||||
color: darken(base, 0.75),
|
||||
weight: 4,
|
||||
opacity: 0.9
|
||||
});
|
||||
trailLayer.addLayer(line);
|
||||
|
||||
// Start + end markers for clarity.
|
||||
const start = L.circleMarker(latlngs[0]!, {
|
||||
radius: 5,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillColor: base,
|
||||
fillOpacity: 1
|
||||
});
|
||||
trailLayer.addLayer(start);
|
||||
|
||||
map.fitBounds(line.getBounds(), { padding: [50, 50], maxZoom: 18 });
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (!container.value || map) return;
|
||||
map = L.map(container.value, { zoomControl: true }).setView([51.0, 10.0], 6);
|
||||
tile = L.tileLayer('https://tile.openstreetmap.de/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap-Mitwirkende',
|
||||
maxZoom: 19
|
||||
});
|
||||
map.addLayer(tile);
|
||||
|
||||
trailLayer = L.layerGroup().addTo(map);
|
||||
answerLayer = L.layerGroup().addTo(map);
|
||||
positionLayer = L.layerGroup().addTo(map);
|
||||
|
||||
renderAnswers();
|
||||
renderPositions();
|
||||
fitOnce();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initMap();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Re-render markers on data/toggle changes (trail preserved separately).
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
renderAnswers();
|
||||
renderPositions();
|
||||
fitOnce();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
watch(() => props.showAnswers, renderAnswers);
|
||||
watch(() => props.showPositions, renderPositions);
|
||||
watch(() => props.selectedUserId, renderTrail, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="livemap" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.livemap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
members: string[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="m-4 flex flex-col justify-center gap-2 rounded-lg p-4 text-4xl shadow-lg"
|
||||
>
|
||||
<span v-for="(member, i) in members" :key="i">
|
||||
{{ member }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
count: number | string;
|
||||
value?: string;
|
||||
count?: number | string;
|
||||
title: string;
|
||||
icon: string;
|
||||
}>();
|
||||
@@ -8,7 +9,7 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h3>{{ Number(count).toLocaleString('de') }}</h3>
|
||||
<h3>{{ value ? value : Number(count).toLocaleString('de') }}</h3>
|
||||
<VaIcon :name="icon" size="10rem" />
|
||||
<p>{{ title }}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user