2026-08-05 13:14:03 +00:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
|
|
|
import L from 'leaflet';
|
2026-08-05 17:32:54 +00:00
|
|
|
import 'leaflet.markercluster';
|
2026-08-05 13:14:03 +00:00
|
|
|
import { useAuthStore } from '../stores/auth';
|
|
|
|
|
import { useGameStore } from '../stores/game';
|
|
|
|
|
import type { Place } from '../stores/game';
|
2026-08-05 17:50:52 +00:00
|
|
|
import type { Team } from '../stores/auth';
|
2026-08-05 13:14:03 +00:00
|
|
|
|
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
const game = useGameStore();
|
|
|
|
|
|
|
|
|
|
const mapEl = ref<HTMLDivElement | null>(null);
|
|
|
|
|
let map: L.Map | null = null;
|
2026-08-05 17:32:54 +00:00
|
|
|
// Clustered — with places synced worldwide (tens of thousands, not the
|
|
|
|
|
// original Madeira-only ~170), rendering every marker individually would
|
|
|
|
|
// choke the DOM. leaflet.markercluster groups nearby markers at low zoom
|
|
|
|
|
// and expands them as you zoom in; each individual marker keeps its own
|
|
|
|
|
// team-colored portalIcon() once visible.
|
|
|
|
|
let markersLayer: L.MarkerClusterGroup | null = null;
|
2026-08-05 13:14:03 +00:00
|
|
|
let linksLayer: L.LayerGroup | null = null;
|
|
|
|
|
let fieldsLayer: L.LayerGroup | null = null;
|
2026-08-05 17:50:52 +00:00
|
|
|
// Cluster icons need to know if anything inside is claimed (and by whom) —
|
|
|
|
|
// leaflet.markercluster only gives back the child L.Marker instances, not
|
|
|
|
|
// our Place data, so track the mapping on the side (WeakMap so old markers
|
|
|
|
|
// from a previous redraw() just fall out rather than needing manual cleanup).
|
|
|
|
|
const markerTeam = new WeakMap<L.Marker, Team | null>();
|
2026-08-05 13:14:03 +00:00
|
|
|
|
|
|
|
|
const selectedPlaceId = ref<number | null>(null);
|
|
|
|
|
const linkSourceId = ref<number | null>(null);
|
|
|
|
|
const actionError = ref<string | null>(null);
|
|
|
|
|
const actionBusy = ref(false);
|
|
|
|
|
|
|
|
|
|
const selectedPlace = computed<Place | null>(
|
|
|
|
|
() => game.places.find((p) => p.id === selectedPlaceId.value) ?? null,
|
|
|
|
|
);
|
|
|
|
|
const linkSourcePlace = computed<Place | null>(
|
|
|
|
|
() => game.places.find((p) => p.id === linkSourceId.value) ?? null,
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-05 14:14:38 +00:00
|
|
|
const TEAM_COLOR: Record<string, string> = { orange: '#ff5f1f', green: '#39ff14' };
|
|
|
|
|
const NEUTRAL_COLOR = '#3fb8c9';
|
2026-08-05 13:14:03 +00:00
|
|
|
|
|
|
|
|
function colorFor(team: string | null): string {
|
|
|
|
|
return team ? TEAM_COLOR[team] : NEUTRAL_COLOR;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 14:14:38 +00:00
|
|
|
function portalIcon(place: Place, isSource: boolean): L.DivIcon {
|
|
|
|
|
const color = isSource ? '#ffffff' : colorFor(place.claim_team);
|
|
|
|
|
const size = isSource ? 22 : place.claim_team ? 18 : 14;
|
|
|
|
|
const claimedClass = place.claim_team ? 'claimed' : '';
|
|
|
|
|
return L.divIcon({
|
|
|
|
|
className: `regress-portal-marker ${claimedClass}`,
|
|
|
|
|
html: `<div style="--portal-glow:${color}; width:${size}px; height:${size}px; border-radius:50%; background:radial-gradient(circle at 35% 35%, ${color}, ${color}33 70%); border:2px solid ${color};"></div>`,
|
|
|
|
|
iconSize: [size, size],
|
|
|
|
|
iconAnchor: [size / 2, size / 2],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 13:14:03 +00:00
|
|
|
function redraw() {
|
|
|
|
|
if (!map || !markersLayer || !linksLayer || !fieldsLayer) return;
|
|
|
|
|
markersLayer.clearLayers();
|
|
|
|
|
linksLayer.clearLayers();
|
|
|
|
|
fieldsLayer.clearLayers();
|
|
|
|
|
|
|
|
|
|
const coordsById = new Map(game.places.map((p) => [p.id, p]));
|
|
|
|
|
|
2026-08-05 17:32:54 +00:00
|
|
|
// addLayers() batch-inserts in one pass — markercluster's recommended way
|
|
|
|
|
// to add anything beyond a handful of markers; looping addLayer() one at a
|
|
|
|
|
// time recalculates the cluster tree on every call and is dramatically
|
|
|
|
|
// slower at this scale (tens of thousands of places).
|
|
|
|
|
const markers = game.places.map((place) => {
|
2026-08-05 13:14:03 +00:00
|
|
|
const isSource = place.id === linkSourceId.value;
|
2026-08-05 14:14:38 +00:00
|
|
|
const marker = L.marker([place.lat, place.lon], { icon: portalIcon(place, isSource) });
|
2026-08-05 13:14:03 +00:00
|
|
|
marker.on('click', () => onMarkerClick(place));
|
|
|
|
|
marker.bindTooltip(place.name || `Place ${place.id}`, { direction: 'top' });
|
2026-08-05 17:50:52 +00:00
|
|
|
markerTeam.set(marker, place.claim_team);
|
2026-08-05 17:32:54 +00:00
|
|
|
return marker;
|
|
|
|
|
});
|
|
|
|
|
markersLayer.addLayers(markers);
|
2026-08-05 13:14:03 +00:00
|
|
|
|
|
|
|
|
for (const link of game.links) {
|
|
|
|
|
const from = coordsById.get(link.from_place_id);
|
|
|
|
|
const to = coordsById.get(link.to_place_id);
|
|
|
|
|
if (!from || !to) continue;
|
|
|
|
|
linksLayer.addLayer(
|
|
|
|
|
L.polyline(
|
|
|
|
|
[
|
|
|
|
|
[from.lat, from.lon],
|
|
|
|
|
[to.lat, to.lon],
|
|
|
|
|
],
|
2026-08-05 14:14:38 +00:00
|
|
|
{
|
|
|
|
|
color: TEAM_COLOR[link.team],
|
|
|
|
|
weight: 2,
|
|
|
|
|
opacity: 0.9,
|
|
|
|
|
className: link.team === 'orange' ? 'regress-link-orange' : 'regress-link-green',
|
|
|
|
|
},
|
2026-08-05 13:14:03 +00:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const field of game.fields) {
|
|
|
|
|
const points = field.placeIds
|
|
|
|
|
.map((id) => coordsById.get(id))
|
|
|
|
|
.filter((p): p is Place => !!p)
|
|
|
|
|
.map((p) => [p.lat, p.lon] as [number, number]);
|
|
|
|
|
if (points.length !== 3) continue;
|
|
|
|
|
fieldsLayer.addLayer(
|
2026-08-05 14:14:38 +00:00
|
|
|
L.polygon(points, {
|
|
|
|
|
color: TEAM_COLOR[field.team],
|
|
|
|
|
weight: 1,
|
|
|
|
|
fillOpacity: 0.18,
|
|
|
|
|
stroke: false,
|
|
|
|
|
className: field.team === 'orange' ? 'regress-field-orange' : 'regress-field-green',
|
|
|
|
|
}),
|
2026-08-05 13:14:03 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onMarkerClick(place: Place) {
|
|
|
|
|
selectedPlaceId.value = place.id;
|
|
|
|
|
actionError.value = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doClaim(place: Place) {
|
|
|
|
|
actionError.value = null;
|
|
|
|
|
actionBusy.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await game.claim(place.id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
actionError.value = err instanceof Error ? err.message : String(err);
|
|
|
|
|
} finally {
|
|
|
|
|
actionBusy.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startLink(place: Place) {
|
|
|
|
|
linkSourceId.value = place.id;
|
|
|
|
|
actionError.value = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelLink() {
|
|
|
|
|
linkSourceId.value = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function completeLink(place: Place) {
|
|
|
|
|
if (!linkSourceId.value) return;
|
|
|
|
|
actionError.value = null;
|
|
|
|
|
actionBusy.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await game.link(linkSourceId.value, place.id);
|
|
|
|
|
linkSourceId.value = null;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
actionError.value = err instanceof Error ? err.message : String(err);
|
|
|
|
|
} finally {
|
|
|
|
|
actionBusy.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doSync() {
|
|
|
|
|
actionError.value = null;
|
|
|
|
|
actionBusy.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await game.syncPlaces();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
actionError.value = err instanceof Error ? err.message : String(err);
|
|
|
|
|
} finally {
|
|
|
|
|
actionBusy.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const orangeScore = computed(() => game.scores.find((s) => s.team === 'orange'));
|
|
|
|
|
const greenScore = computed(() => game.scores.find((s) => s.team === 'green'));
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
if (mapEl.value) {
|
2026-08-05 17:32:54 +00:00
|
|
|
// World view by default now that places are synced globally, not just
|
|
|
|
|
// Madeira — falls back to this if geolocation isn't available/granted.
|
|
|
|
|
map = L.map(mapEl.value, { zoomControl: false }).setView([20, 0], 2);
|
2026-08-05 14:14:38 +00:00
|
|
|
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
|
|
|
attribution: '© OpenStreetMap contributors © CARTO',
|
|
|
|
|
subdomains: 'abcd',
|
2026-08-05 13:14:03 +00:00
|
|
|
maxZoom: 19,
|
|
|
|
|
}).addTo(map);
|
|
|
|
|
fieldsLayer = L.layerGroup().addTo(map);
|
|
|
|
|
linksLayer = L.layerGroup().addTo(map);
|
2026-08-05 17:32:54 +00:00
|
|
|
markersLayer = L.markerClusterGroup({
|
|
|
|
|
maxClusterRadius: 60,
|
|
|
|
|
spiderfyOnMaxZoom: true,
|
|
|
|
|
iconCreateFunction: (cluster) => {
|
|
|
|
|
const count = cluster.getChildCount();
|
|
|
|
|
const size = count < 100 ? 34 : count < 1000 ? 42 : 50;
|
2026-08-05 17:50:52 +00:00
|
|
|
|
|
|
|
|
let hasOrange = false;
|
|
|
|
|
let hasGreen = false;
|
|
|
|
|
for (const marker of cluster.getAllChildMarkers()) {
|
|
|
|
|
const team = markerTeam.get(marker as L.Marker);
|
|
|
|
|
if (team === 'orange') hasOrange = true;
|
|
|
|
|
else if (team === 'green') hasGreen = true;
|
|
|
|
|
if (hasOrange && hasGreen) break;
|
|
|
|
|
}
|
|
|
|
|
const modifier = hasOrange && hasGreen ? 'contested' : hasOrange ? 'orange' : hasGreen ? 'green' : 'neutral';
|
|
|
|
|
|
2026-08-05 17:32:54 +00:00
|
|
|
return L.divIcon({
|
2026-08-05 17:50:52 +00:00
|
|
|
className: `regress-cluster regress-cluster--${modifier}`,
|
2026-08-05 17:32:54 +00:00
|
|
|
html: `<div style="width:${size}px;height:${size}px;">${count}</div>`,
|
|
|
|
|
iconSize: [size, size],
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
}).addTo(map);
|
|
|
|
|
|
|
|
|
|
navigator.geolocation?.getCurrentPosition(
|
|
|
|
|
(pos) => map?.setView([pos.coords.latitude, pos.coords.longitude], 13),
|
|
|
|
|
() => {}, // silently keep the world view if denied/unavailable
|
|
|
|
|
{ timeout: 5000 },
|
|
|
|
|
);
|
2026-08-05 13:14:03 +00:00
|
|
|
}
|
|
|
|
|
await game.refreshAll();
|
|
|
|
|
redraw();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
watch(() => [game.places, game.links, game.fields], redraw, { deep: true });
|
|
|
|
|
watch(linkSourceId, redraw);
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-08-05 14:14:38 +00:00
|
|
|
<div class="flex h-full flex-col bg-void">
|
|
|
|
|
<header class="hud-panel z-[1000] m-2 flex items-center justify-between gap-4 px-4 py-2 text-sm">
|
|
|
|
|
<div class="flex items-center gap-5">
|
|
|
|
|
<span class="font-display text-lg font-black tracking-widest text-cyan-neon text-glow-cyan">REGRESS</span>
|
|
|
|
|
<span class="flex items-center gap-1 font-bold text-orange-neon text-glow-orange">
|
2026-08-05 23:05:04 +00:00
|
|
|
▲ {{ orangeScore?.claimedPlaces ?? 0 }} <span class="text-orange-neon/90">nodes</span> ·
|
|
|
|
|
{{ orangeScore?.links ?? 0 }} <span class="text-orange-neon/90">links</span> ·
|
|
|
|
|
{{ orangeScore?.fields ?? 0 }} <span class="text-orange-neon/90">fields</span> ·
|
2026-08-05 13:14:03 +00:00
|
|
|
{{ (orangeScore?.areaKm2 ?? 0).toFixed(2) }} km²
|
|
|
|
|
</span>
|
2026-08-05 14:14:38 +00:00
|
|
|
<span class="flex items-center gap-1 font-bold text-green-neon text-glow-green">
|
2026-08-05 23:05:04 +00:00
|
|
|
▲ {{ greenScore?.claimedPlaces ?? 0 }} <span class="text-green-neon/90">nodes</span> ·
|
|
|
|
|
{{ greenScore?.links ?? 0 }} <span class="text-green-neon/90">links</span> ·
|
|
|
|
|
{{ greenScore?.fields ?? 0 }} <span class="text-green-neon/90">fields</span> ·
|
2026-08-05 13:14:03 +00:00
|
|
|
{{ (greenScore?.areaKm2 ?? 0).toFixed(2) }} km²
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-3">
|
2026-08-05 23:05:04 +00:00
|
|
|
<span class="text-cyan-neon/95">
|
2026-08-05 14:14:38 +00:00
|
|
|
{{ auth.displayName ?? auth.pubkey?.slice(0, 8) }}//<span
|
|
|
|
|
:class="auth.team === 'orange' ? 'text-orange-neon text-glow-orange' : 'text-green-neon text-glow-green'"
|
|
|
|
|
class="font-bold uppercase"
|
|
|
|
|
>{{ auth.team }}</span
|
|
|
|
|
>
|
2026-08-05 13:14:03 +00:00
|
|
|
</span>
|
2026-08-05 14:14:38 +00:00
|
|
|
<button
|
|
|
|
|
class="rounded border border-cyan-neon/50 bg-black/40 px-3 py-1 text-cyan-neon transition hover:border-cyan-neon hover:shadow-glow-cyan"
|
|
|
|
|
:disabled="actionBusy"
|
|
|
|
|
@click="doSync"
|
|
|
|
|
>
|
|
|
|
|
↻ Sync BTC Map
|
2026-08-05 13:14:03 +00:00
|
|
|
</button>
|
2026-08-05 14:14:38 +00:00
|
|
|
<button
|
|
|
|
|
class="rounded border border-magenta-neon/50 bg-black/40 px-3 py-1 text-magenta-neon transition hover:border-magenta-neon hover:shadow-glow-magenta"
|
|
|
|
|
@click="auth.logout()"
|
|
|
|
|
>
|
|
|
|
|
Disconnect
|
2026-08-05 13:14:03 +00:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div class="relative flex-1">
|
|
|
|
|
<div ref="mapEl" class="h-full w-full"></div>
|
|
|
|
|
|
2026-08-05 14:14:38 +00:00
|
|
|
<div v-if="linkSourcePlace" class="hud-panel absolute left-4 top-4 z-[1000] px-4 py-2 text-sm text-cyan-neon">
|
|
|
|
|
LINKING FROM <strong class="text-glow-cyan">{{ linkSourcePlace.name }}</strong> — select a friendly node, or
|
|
|
|
|
<button class="ml-2 text-magenta-neon underline" @click="cancelLink">abort</button>
|
2026-08-05 13:14:03 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-08-05 14:14:38 +00:00
|
|
|
<div v-if="selectedPlace" class="hud-panel absolute right-4 top-4 z-[1000] w-72 p-4 text-sm">
|
2026-08-05 13:14:03 +00:00
|
|
|
<div class="mb-1 flex items-start justify-between gap-2">
|
2026-08-05 14:14:38 +00:00
|
|
|
<h2 class="font-display font-bold text-cyan-neon text-glow-cyan">
|
|
|
|
|
{{ selectedPlace.name || `Node ${selectedPlace.id}` }}
|
|
|
|
|
</h2>
|
2026-08-05 23:05:04 +00:00
|
|
|
<button class="text-cyan-neon/80 hover:text-cyan-neon" @click="selectedPlaceId = null">✕</button>
|
2026-08-05 13:14:03 +00:00
|
|
|
</div>
|
2026-08-05 23:05:04 +00:00
|
|
|
<p v-if="selectedPlace.address" class="mb-2 text-cyan-neon/80">{{ selectedPlace.address }}</p>
|
2026-08-05 13:14:03 +00:00
|
|
|
<p class="mb-3">
|
2026-08-05 14:14:38 +00:00
|
|
|
STATUS:
|
2026-08-05 23:05:04 +00:00
|
|
|
<span v-if="!selectedPlace.claim_team" class="text-cyan-neon/90">UNCLAIMED</span>
|
2026-08-05 14:14:38 +00:00
|
|
|
<span
|
|
|
|
|
v-else
|
|
|
|
|
class="font-bold uppercase"
|
|
|
|
|
:class="selectedPlace.claim_team === 'orange' ? 'text-orange-neon text-glow-orange' : 'text-green-neon text-glow-green'"
|
|
|
|
|
>
|
2026-08-05 17:50:52 +00:00
|
|
|
held since block {{ selectedPlace.claimed_at_block ?? '?' }}
|
2026-08-05 13:14:03 +00:00
|
|
|
</span>
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-col gap-2">
|
|
|
|
|
<button
|
|
|
|
|
v-if="selectedPlace.claim_team !== auth.team"
|
2026-08-05 14:14:38 +00:00
|
|
|
class="rounded border px-3 py-2 font-bold uppercase transition disabled:opacity-50"
|
|
|
|
|
:class="
|
|
|
|
|
auth.team === 'orange'
|
|
|
|
|
? 'border-orange-neon text-orange-neon hover:shadow-glow-orange'
|
|
|
|
|
: 'border-green-neon text-green-neon hover:shadow-glow-green'
|
|
|
|
|
"
|
2026-08-05 13:14:03 +00:00
|
|
|
:disabled="actionBusy"
|
|
|
|
|
@click="doClaim(selectedPlace)"
|
|
|
|
|
>
|
|
|
|
|
Claim for {{ auth.team }}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
v-else-if="linkSourceId !== selectedPlace.id"
|
2026-08-05 14:14:38 +00:00
|
|
|
class="rounded border border-cyan-neon/50 px-3 py-2 text-cyan-neon transition hover:border-cyan-neon hover:shadow-glow-cyan disabled:opacity-50"
|
2026-08-05 13:14:03 +00:00
|
|
|
:disabled="actionBusy"
|
|
|
|
|
@click="startLink(selectedPlace)"
|
|
|
|
|
>
|
|
|
|
|
Link from here
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
v-if="linkSourceId && linkSourceId !== selectedPlace.id && selectedPlace.claim_team === auth.team"
|
2026-08-05 14:14:38 +00:00
|
|
|
class="rounded border border-cyan-neon/50 px-3 py-2 text-cyan-neon transition hover:border-cyan-neon hover:shadow-glow-cyan disabled:opacity-50"
|
2026-08-05 13:14:03 +00:00
|
|
|
:disabled="actionBusy"
|
|
|
|
|
@click="completeLink(selectedPlace)"
|
|
|
|
|
>
|
|
|
|
|
Complete link to here
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-05 14:14:38 +00:00
|
|
|
<p v-if="actionError" class="mt-3 text-magenta-neon">{{ actionError }}</p>
|
2026-08-05 13:14:03 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
v-if="!game.places.length && !game.loading"
|
2026-08-05 14:14:38 +00:00
|
|
|
class="absolute inset-0 z-[999] flex items-center justify-center bg-black/70"
|
2026-08-05 13:14:03 +00:00
|
|
|
>
|
2026-08-05 14:14:38 +00:00
|
|
|
<div class="hud-panel p-6 text-center">
|
|
|
|
|
<p class="mb-3 text-cyan-neon">NO NODES DETECTED — pull the live Madeira dataset from BTC Map.</p>
|
|
|
|
|
<button
|
|
|
|
|
class="rounded border border-cyan-neon px-4 py-2 font-bold text-cyan-neon hover:shadow-glow-cyan"
|
|
|
|
|
:disabled="actionBusy"
|
|
|
|
|
@click="doSync"
|
|
|
|
|
>
|
|
|
|
|
↻ Sync from BTC Map
|
2026-08-05 13:14:03 +00:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|