@@ -171,6 +172,9 @@ async function deleteTeam() {
by {{ team.owner.name }} ({{ team.owner.email }})
{{ team._count.answers }} answers
+
+ {{ t('page.common.distance') }}: {{ formatDistance(team.distance) }}
+
+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;
+ 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(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();
+ for (const team of props.data.teams) m.set(team.id, team.name);
+ return m;
+});
+const userName = computed(() => {
+ const m = new Map();
+ 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();
+ 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: ``,
+ 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
+ ? `
`
+ : '';
+ return ``;
+}
+
+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
+ ? `
${t('page.map.accuracy')}: ${Math.round(p.accuracy)} m`
+ : '';
+ return `
+ ${name}
+ ${t('page.map.team')}: ${team}
+ ${t('page.map.last_fix', { ago: relTime(p.recordedAt) })}${acc}
+
`;
+}
+
+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 });
+
+
+
+
+
+
+
diff --git a/app/components/showcase/MemberCard.vue b/app/components/showcase/MemberCard.vue
new file mode 100644
index 0000000..1d1d793
--- /dev/null
+++ b/app/components/showcase/MemberCard.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+ {{ member }}
+
+
+
+
+
diff --git a/app/components/showcase/NumberCard.vue b/app/components/showcase/NumberCard.vue
index 6eaf3f9..74b6ab9 100644
--- a/app/components/showcase/NumberCard.vue
+++ b/app/components/showcase/NumberCard.vue
@@ -1,6 +1,7 @@
+
+
+
+
+
+ {{ t(parseError(error)) }}
+
+
+ {{ t('page.common.back_to_hunt') }}
+
+
+
+
+
{{ t('page.map.title') }}
+
+
+
+
+ {{ t('page.common.refresh') }}
+
+
+
+ {{
+ t('page.map.last_updated', {
+ time: lastUpdated.toLocaleTimeString()
+ })
+ }}
+
+
+
+
+
+
+
+
+ {{ t('page.map.roster') }}
+
+
+ {{ t('page.map.no_teams') }}
+
+
+ -
+
+
+ {{ team.name }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/hunt/[huntSlug]/showcase.vue b/app/pages/hunt/[huntSlug]/showcase.vue
index 298557b..ba95bc9 100644
--- a/app/pages/hunt/[huntSlug]/showcase.vue
+++ b/app/pages/hunt/[huntSlug]/showcase.vue
@@ -1,6 +1,7 @@
@@ -76,9 +90,9 @@ const columns = leaderboardColumns.map((c) =>
:count="data.leaderboard[2].answers"
icon="question_mark"
/>
+
+
@@ -210,6 +230,12 @@ const columns = leaderboardColumns.map((c) =>
:count="data.leaderboard[1].answers"
icon="question_mark"
/>
+
+
@@ -236,6 +262,11 @@ const columns = leaderboardColumns.map((c) =>
:count="data.leaderboard[0].answers"
icon="question_mark"
/>
+