feat(hunt): track walking distance via GPS, show live locations on map
Docker Image CI / test (push) Failing after 41s
Docker Image CI / deploy (push) Skipped
Tests / unit (push) Failing after 36s
Tests / integration (push) Successful in 1m34s

Add foreground-only GPS distance tracking for hunts
This commit is contained in:
2026-09-27 20:55:58 +02:00
parent a0fc836c05
commit 61cc516f47
43 changed files with 2313 additions and 24 deletions
+274
View File
@@ -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: '&copy; 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>