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,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>
|
||||
Reference in New Issue
Block a user