275 lines
7.5 KiB
Vue
275 lines
7.5 KiB
Vue
<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>
|