SkateSpots/src/components/SpotMap.tsx

1760 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useEffect, useRef, useMemo, useCallback } from 'react';
import L from 'leaflet';
import 'leaflet.markercluster';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import ngeohash from 'ngeohash';
import { useNavigate } from 'react-router-dom';
import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent, SkateCrew } from '@/lib/types';
import { formatPrice } from '@/lib/types';
import { spotTypeLabel } from '@/lib/spotTypeLabel';
import type { SpotWeatherData } from '@/hooks/useSpotWeather';
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
import { FORECAST_DAYS } from '@/lib/weather';
import { useTranslation } from '@/contexts/language';
/** Translation function shape, so module-level popup builders can receive `t`. */
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* The map never shows a *second full copy* of the world. `applyWorldMinZoom`
* clamps the minimum zoom so the 256·2^z world is at least as large as the
* viewport. The map can still wrap across the antimeridian (so Hawaii and the
* Pacific are reachable by dragging), but at that zoom you only ever see a
* sliver of the adjacent copy — never duplicated continents.
*/
function applyWorldMinZoom(map: L.Map): void {
// The world is a 256·2^z square. To cover the viewport (no grey bands, no
// repeated continents) pick the smallest integer zoom whose world is at
// least as large as the larger viewport dimension. Using the container's
// client size directly avoids the 0×0 size Leaflet reports before layout.
const el = map.getContainer();
const width = el.clientWidth || map.getSize().x;
const height = el.clientHeight || map.getSize().y;
if (!width || !height) return;
const min = Math.max(1, Math.ceil(Math.log2(Math.max(width, height) / 256)));
map.setMinZoom(min);
if (map.getZoom() < min) map.setZoom(min, { animate: false });
}
/**
* SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping.
* Every value that originates from a Nostr event (spot/tour/crew/event fields)
* or a third-party API (BTC Map places) is UNTRUSTED and must never be placed
* into `innerHTML`. These helpers build popup rows with `textContent` (which
* cannot execute markup) and validate URLs with `sanitizeUrl` before they touch
* an `href`/`src`. See .agents/skills/nostr-security.
*/
/**
* Escape a value for safe interpolation into an innerHTML template literal.
* Weather/air-quality fields come from an external API, so they're untrusted —
* a compromised or malicious upstream could otherwise inject HTML/JS.
*/
function escapeHtml(value: unknown): string {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/** A popup detail row: an emoji/icon prefix + untrusted text set via textContent. */
function popupRow(prefix: string, text: string): HTMLElement {
const row = document.createElement('div');
row.className = 'flex items-center gap-2';
const label = document.createElement('span');
// Prefix is always a hardcoded emoji/label — safe as textContent too.
label.textContent = prefix;
const value = document.createElement('span');
value.textContent = text; // untrusted — textContent neutralizes any markup
row.appendChild(label);
row.appendChild(value);
return row;
}
// Fix default marker icon issues with webpack/vite
const defaultIcon = L.icon({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
L.Marker.prototype.options.icon = defaultIcon;
/** Emoji glyph per spot category — turns the map into a readable language. */
/** A single generic glyph for every skate-spot pin (kept intentionally simple). */
const SPOT_GLYPH = '🛹';
/**
* Crisp skate-spot marker: a solid neon teardrop with a TIGHT glow halo (the
* glow is an accent, not the whole blob), a generic skate glyph inside, and —
* for covered/indoor spots — a small roof "cap" so dry spots literally stand
* out on a rainy day.
*
* `weatherColor`/`weatherIcon` (when the weather layer is on) still override the
* fill + add the condition badge, exactly as before.
*/
const createSkateSpotIcon = (opts?: {
weatherColor?: string;
weatherIcon?: string;
shelter?: 'outdoor' | 'partial' | 'covered' | 'indoor';
}) => {
const { weatherColor, weatherIcon, shelter } = opts ?? {};
const fill = weatherColor
? `background: ${weatherColor};`
: 'background: linear-gradient(135deg, #ff0040, #ff6600);';
const glowColor = weatherColor || '#ff0040';
const glyph = SPOT_GLYPH;
const isSheltered = shelter === 'covered' || shelter === 'indoor';
// Roof cap for covered/indoor spots — a small cyan "dry" marker that sits
// above the pin so it reads even when zoomed out.
const roofCap = isSheltered
? `
<div style="
position: absolute;
top: -7px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
width: 18px;
height: 9px;
background: linear-gradient(135deg, #22d3ee, #818cf8);
border-radius: 9px 9px 0 0;
border: 2px solid rgba(255,255,255,0.9);
border-bottom: none;
box-shadow: 0 0 8px rgba(34,211,238,0.8);
"></div>`
: '';
return L.divIcon({
className: 'skate-spot-marker',
html: `
<div class="ss-pin" style="position: relative; width: 36px; height: 36px;">
${roofCap}
<div style="
width: 36px;
height: 36px;
${fill}
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
border: 2.5px solid #fff;
box-shadow: 0 0 0 1px rgba(0,0,0,0.35), 0 0 10px ${glowColor}, 0 3px 6px rgba(0,0,0,0.45);
position: relative;
">
${weatherIcon ? `
<div style="
position: absolute;
top: -8px;
right: -8px;
background: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
transform: rotate(45deg);
">${weatherIcon}</div>
` : ''}
<span style="
transform: rotate(45deg);
font-size: 16px;
line-height: 1;
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.5));
">${glyph}</span>
</div>
</div>
`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
});
};
// Custom shop marker icon (for shops)
const createShopIcon = () => L.divIcon({
className: 'skate-shop-marker',
html: `
<div style="
width: 36px;
height: 36px;
background: linear-gradient(135deg, #ccff00, #00ff88);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(204,255,0,0.5);
border: 2px solid #fff;
">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M3 9h18v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z"/>
<path d="m3 9 2.45-4.9A2 2 0 0 1 7.24 3h9.52a2 2 0 0 1 1.8 1.1L21 9"/>
<path d="M12 3v6"/>
</svg>
</div>
`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
});
// "You are here" user-location marker — SkateSpots logo in a circular badge
// with a pulsing accuracy halo (Google-Maps-style, brand-flavored).
const createUserLocationIcon = (picture?: string | null) => {
// BASE_URL resolves correctly for both web ('/') and the Capacitor APK ('./').
// Reuse the small (~24 KB) brand logo for the marker instead of the old
// 2.9 MB user-location.png — same artwork, ~99% smaller payload.
const logoUrl = `${import.meta.env.BASE_URL}images/logo.png`;
// Show the user's own Nostr profile picture (their npub's "logo") when set.
// Layered CSS backgrounds mean a broken/expired avatar URL silently reveals
// the brand logo underneath — no inline JS (CSP-safe).
const safeAvatar = picture ? sanitizeUrl(picture) : null;
const avatarCss = safeAvatar
? encodeURI(safeAvatar).replace(/[()'"]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
: '';
const backgroundImage = avatarCss ? `url('${avatarCss}'), url('${logoUrl}')` : `url('${logoUrl}')`;
const backgroundSize = avatarCss ? 'cover, cover' : 'cover';
return L.divIcon({
className: 'user-location-marker',
html: `
<div style="position: relative; width: 64px; height: 64px;">
<!-- pulsing accuracy halo -->
<span style="
position: absolute;
left: 50%; top: 50%;
width: 64px; height: 64px;
margin: -32px 0 0 -32px;
border-radius: 50%;
background: rgba(255,0,64,0.25);
animation: ssPulse 2s ease-out infinite;
"></span>
<!-- avatar / logo badge -->
<div style="
position: absolute;
left: 50%; top: 50%;
width: 40px; height: 40px;
margin: -20px 0 0 -20px;
border-radius: 50%;
background-image: ${backgroundImage};
background-size: ${backgroundSize};
background-position: center;
background-color: #fff;
border: 3px solid #ff0040;
box-shadow: 0 3px 10px rgba(0,0,0,0.5);
"></div>
</div>
`,
iconSize: [64, 64],
iconAnchor: [32, 32], // center anchored (it marks a point, not a pin drop)
popupAnchor: [0, -20],
});
};
// Dual-purpose marker (shop + spot)
const createDualIcon = (hasGear?: boolean, gearCount?: number) => L.divIcon({
className: 'dual-marker',
html: `
<div style="
width: 44px;
height: 44px;
position: relative;
transform: rotate(-45deg);
">
<!-- Main container -->
<div style="
width: 100%;
height: 100%;
background: linear-gradient(135deg, #9333ea 50%, #ff0040 50%);
border-radius: 50% 50% 50% 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(147,51,234,0.5);
border: 2px solid #fff;
">
<!-- Dual icons: shop + spot -->
<div style="transform: rotate(45deg); display: flex; gap: 2px;">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9h18v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z"/>
<path d="m3 9 2.45-4.9A2 2 0 0 1 7.24 3h9.52a2 2 0 0 1 1.8 1.1L21 9"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="#fff" stroke="#fff" stroke-width="2">
<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>
</svg>
</div>
</div>
${hasGear && gearCount ? `
<div style="
position: absolute;
bottom: -6px;
right: -6px;
background: #ff6600;
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
border: 2px solid #fff;
transform: rotate(45deg);
">${gearCount}</div>
` : ''}
</div>
`,
iconSize: [44, 44],
iconAnchor: [22, 44],
popupAnchor: [0, -44],
});
// Custom tour marker icon (cyan color with route/person icon)
const createTourIcon = () => L.divIcon({
className: 'tour-marker',
html: `
<div style="
width: 40px;
height: 40px;
background: linear-gradient(135deg, #06b6d4, #0891b2);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(6,182,212,0.5);
border: 3px solid #fff;
position: relative;
">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<circle cx="12" cy="8" r="5"/>
<path d="M20 21a8 8 0 0 0-16 0"/>
</svg>
<div style="
position: absolute;
top: -6px;
right: -6px;
background: #fbbf24;
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
border: 2px solid #fff;
transform: rotate(45deg);
">🛹</div>
</div>
`,
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40],
});
// Custom event marker icon (lime — matches the Events section + Add Event action)
const createEventIcon = () => L.divIcon({
className: 'event-marker',
html: `
<div style="
width: 38px;
height: 38px;
background: linear-gradient(135deg, #ccff00, #a3cc00);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(204,255,0,0.45);
border: 3px solid #fff;
">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0a0a0a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>
</svg>
</div>
`,
iconSize: [38, 38],
iconAnchor: [19, 38],
popupAnchor: [0, -38],
});
// Custom crew marker icon (teal/cyan — crew home turf)
const createCrewIcon = (logo?: string) => {
// SECURITY: crew.image is untrusted. Only embed it when it's a valid http(s)
// URL; sanitizeUrl returns a parsed href with quotes percent-encoded, so it
// cannot break out of the double-quoted src="" attribute below.
const safeLogo = sanitizeUrl(logo);
return L.divIcon({
className: 'crew-marker',
html: `
<div style="
width: 42px;
height: 42px;
background: linear-gradient(135deg, #14b8a6, #06b6d4);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 14px rgba(20,184,166,0.55);
border: 3px solid #fff;
overflow: hidden;
">
${safeLogo ? `
<img src="${safeLogo}" style="
width: 100%;
height: 100%;
object-fit: cover;
transform: rotate(45deg) scale(1.5);
" />
` : `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
`}
</div>
`,
iconSize: [42, 42],
iconAnchor: [21, 42],
popupAnchor: [0, -42],
});
};
// Custom BTC Map marker icon (for Bitcoin-accepting places)
const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({
className: 'btcmap-marker',
html: `
<div style="
width: 32px;
height: 32px;
background: linear-gradient(135deg, #f7931a, #ffb347);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(247,147,26,0.5);
border: 2px solid ${isBoosted ? '#ffd700' : '#fff'};
position: relative;
">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#fff" style="transform: rotate(45deg);">
<path d="M12.5 8.8v-3h2.1c.6 0 1.1.1 1.4.4.4.3.5.7.5 1.2 0 .5-.2.9-.5 1.1-.4.2-.8.3-1.4.3h-2.1zm0 5.4v-3.5h2.3c.7 0 1.2.2 1.6.5.4.3.6.8.6 1.3 0 .5-.2 1-.6 1.3-.4.3-.9.5-1.6.5h-2.3zm-2 2.8V5h4.6c1.2 0 2.1.3 2.8.9.7.6 1 1.4 1 2.4 0 .7-.2 1.3-.5 1.8-.3.5-.8.8-1.4 1 .7.1 1.3.5 1.7 1 .4.5.6 1.2.6 2 0 1.1-.4 2-1.1 2.6-.7.6-1.7.9-3 .9h-4.7z"/>
</svg>
${isBoosted ? `
<div style="
position: absolute;
top: -6px;
right: -6px;
background: #ffd700;
border-radius: 50%;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
border: 2px solid #fff;
transform: rotate(45deg);
">⭐</div>
` : ''}
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32],
});
// Custom marketplace marker icon (for gear swap)
const createMarketplaceIcon = (count?: number) => L.divIcon({
className: 'marketplace-marker',
html: `
<div style="
width: 36px;
height: 36px;
background: linear-gradient(135deg, #ff6600, #ff9933);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255,102,0,0.5);
border: 2px solid #fff;
position: relative;
">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
<path d="M3 6h18"/>
<path d="M16 10a4 4 0 0 1-8 0"/>
</svg>
${count && count > 1 ? `
<div style="
position: absolute;
top: -8px;
right: -8px;
background: #ff0040;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
border: 2px solid #fff;
transform: rotate(45deg);
">${count}</div>
` : ''}
</div>
`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
});
interface SpotMapProps {
spots: SkateSpot[];
marketplaceItems?: MarketplaceItem[];
tours?: SkateTour[];
events?: SkateEvent[];
crews?: SkateCrew[];
btcMapPlaces?: BtcMapPlace[];
center?: [number, number];
zoom?: number;
onSpotClick?: (spot: SkateSpot) => void;
selectedSpot?: SkateSpot | null;
onMapClick?: (lat: number, lng: number) => void;
onMapMove?: (center: { lat: number; lon: number; zoom?: number }) => void;
enableClickToAdd?: boolean;
className?: string;
weatherEnabled?: boolean;
weatherData?: Map<string, SpotWeatherData>;
showSpots?: boolean;
showShops?: boolean;
showGear?: boolean;
showTours?: boolean;
showEvents?: boolean;
showCrews?: boolean;
showBtcMap?: boolean;
flyTo?: { lat: number; lng: number; zoom?: number } | null;
/** The current user's location — renders a "you are here" pin when set. */
userLocation?: { lat: number; lng: number } | null;
/** The current user's Nostr profile picture, used on the "you are here" pin. */
userPicture?: string | null;
/** A pending spot location the user tapped to add (shown before confirming). */
pendingPin?: { lat: number; lng: number } | null;
}
function createPopupContent(
spot: SkateSpot,
onViewDetails: () => void,
weatherData: SpotWeatherData | undefined,
t: Translate,
): HTMLElement {
const container = document.createElement('div');
container.className = 'p-2 min-w-[200px]';
// Title
const title = document.createElement('h3');
title.className = 'font-bold text-lg mb-1';
title.textContent = spot.title;
container.appendChild(title);
// Location — SVG is static markup, but spot.location is UNTRUSTED, so it is
// written via textContent (never innerHTML) to neutralize any injected markup.
if (spot.location) {
const location = document.createElement('p');
location.className = 'text-sm text-gray-300 mb-2 flex items-center gap-1';
const pin = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
pin.setAttribute('class', 'h-3 w-3');
pin.setAttribute('viewBox', '0 0 24 24');
pin.setAttribute('fill', 'none');
pin.setAttribute('stroke', 'currentColor');
pin.setAttribute('stroke-width', '2');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z');
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', '12');
circle.setAttribute('cy', '10');
circle.setAttribute('r', '3');
pin.appendChild(path);
pin.appendChild(circle);
const text = document.createElement('span');
text.textContent = spot.location;
location.appendChild(pin);
location.appendChild(text);
container.appendChild(location);
}
// Weather section (if enabled and available)
if (weatherData) {
const weatherSection = document.createElement('div');
weatherSection.className = 'mb-3 p-2 rounded-lg bg-white/[0.04] border border-white/10';
// Current conditions
const currentWeather = document.createElement('div');
currentWeather.className = 'flex items-center justify-between mb-2';
currentWeather.innerHTML = `
<div class="flex items-center gap-2">
<span class="text-2xl">${escapeHtml(weatherData.weather.weatherIcon)}</span>
<div>
<div class="font-semibold">${escapeHtml(weatherData.weather.temperature)}°C</div>
<div class="text-xs text-gray-300">${escapeHtml(t(weatherData.weather.weatherDescriptionKey))}</div>
</div>
</div>
<div class="text-right">
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
${escapeHtml(t('mapUi.conditions'))} · ${escapeHtml(t(weatherData.skateability.labelKey))}
</div>
<div class="text-xs text-gray-300">${escapeHtml(t('mapUi.weatherScoreSuffix', { score: weatherData.skateability.score }))}</div>
</div>
`;
weatherSection.appendChild(currentWeather);
// Details
const details = document.createElement('div');
details.className = 'text-xs space-y-1 text-gray-600';
details.innerHTML = `
<div>💨 ${escapeHtml(t('mapUi.wind'))}: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div>
<div>💧 ${escapeHtml(t('mapUi.humidity'))}: ${escapeHtml(weatherData.weather.humidity)}%</div>
${weatherData.airQuality ? `<div>🌫️ ${escapeHtml(t('mapUi.airQuality'))}: ${escapeHtml(t(weatherData.airQuality.aqiLabelKey))} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''}
`;
weatherSection.appendChild(details);
// Forecast (if available)
if (weatherData.forecast?.daily && weatherData.forecast.daily.length > 0) {
const forecastDiv = document.createElement('div');
forecastDiv.className = 'mt-2 pt-2 border-t border-gray-200';
const forecastTitle = document.createElement('div');
forecastTitle.className = 'text-xs font-semibold mb-1 text-gray-700';
forecastTitle.textContent = t('mapUi.forecastDays', { count: FORECAST_DAYS });
forecastDiv.appendChild(forecastTitle);
const forecastGrid = document.createElement('div');
forecastGrid.className = 'grid grid-cols-3 gap-1 text-xs text-center';
weatherData.forecast.daily.forEach((day) => {
const dayDiv = document.createElement('div');
dayDiv.className = 'p-1';
const date = new Date(day.date);
const dayName = date.toLocaleDateString(dateLocaleCode(), { weekday: 'short' });
dayDiv.innerHTML = `
<div class="font-semibold text-gray-700">${escapeHtml(dayName)}</div>
<div class="text-lg my-1">${escapeHtml(day.weatherIcon)}</div>
<div class="text-gray-600">${escapeHtml(day.maxTemp)}°/${escapeHtml(day.minTemp)}°</div>
${day.precipitationProbability > 0 ? `<div class="text-blue-600">${escapeHtml(day.precipitationProbability)}% 🌧️</div>` : ''}
`;
forecastGrid.appendChild(dayDiv);
});
forecastDiv.appendChild(forecastGrid);
weatherSection.appendChild(forecastDiv);
}
container.appendChild(weatherSection);
}
// Types
if (spot.types.length > 0) {
const typesContainer = document.createElement('div');
typesContainer.className = 'flex flex-wrap gap-1 mb-3';
spot.types.slice(0, 3).forEach((type) => {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold bg-gray-100 text-gray-800';
badge.textContent = spotTypeLabel(t, type);
typesContainer.appendChild(badge);
});
container.appendChild(typesContainer);
}
// Button
const button = document.createElement('button');
button.className = 'w-full inline-flex items-center justify-center rounded-md text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 h-8 px-3 transition-colors';
button.textContent = t('mapUi.viewDetails');
button.onclick = (e) => {
e.preventDefault();
onViewDetails();
};
container.appendChild(button);
return container;
}
// Enhanced popup with marketplace items section
function createEnhancedPopupContent(
spot: SkateSpot,
marketplaceItems: MarketplaceItem[],
onViewDetails: () => void,
t: Translate,
): HTMLElement {
const container = document.createElement('div');
container.className = 'p-2 min-w-[250px] max-w-[350px]';
// Location name header
const header = document.createElement('div');
header.className = 'mb-3 pb-2 border-b border-gray-700';
const locationName = document.createElement('h3');
locationName.className = 'font-bold text-lg text-white';
locationName.textContent = `📍 ${spot.title}`;
header.appendChild(locationName);
container.appendChild(header);
// Spot/Shop Section
const spotSection = document.createElement('div');
spotSection.className = 'mb-3';
const isShop = spot.types.includes('shop');
const spotIcon = isShop ? '🏪' : '🛹';
const spotLabel = isShop ? t('mapUi.skateShop') : t('mapUi.skateSpot');
const spotTitle = document.createElement('div');
spotTitle.className = 'font-bold text-sm text-gray-200 mb-2';
spotTitle.textContent = `${spotIcon} ${spotLabel.toUpperCase()}`;
spotSection.appendChild(spotTitle);
// Spot types
if (spot.types.length > 0) {
const typesContainer = document.createElement('div');
typesContainer.className = 'flex flex-wrap gap-1 mb-2';
spot.types.slice(0, 3).forEach((type) => {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold bg-gray-800 text-gray-200 border-gray-600';
badge.textContent = spotTypeLabel(t, type);
typesContainer.appendChild(badge);
});
spotSection.appendChild(typesContainer);
}
// View spot button
const spotButton = document.createElement('button');
spotButton.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-medium bg-red-500 text-white hover:bg-red-600 h-7 px-3 transition-colors mt-2';
spotButton.textContent = t('mapUi.viewDetailsArrow');
spotButton.onclick = (e) => {
e.preventDefault();
onViewDetails();
};
spotSection.appendChild(spotButton);
container.appendChild(spotSection);
// Marketplace Section (if items exist)
if (marketplaceItems.length > 0) {
const separator = document.createElement('div');
separator.className = 'border-t border-gray-700 my-3';
container.appendChild(separator);
const gearSection = document.createElement('div');
const gearTitle = document.createElement('div');
gearTitle.className = 'font-bold text-sm text-orange-400 mb-2';
gearTitle.textContent = `🛒 ${t('mapUi.gearForSale', { count: marketplaceItems.length }).toUpperCase()}`;
gearSection.appendChild(gearTitle);
// Show up to 3 items
const itemsList = document.createElement('div');
itemsList.className = 'space-y-2 mb-2';
marketplaceItems.slice(0, 3).forEach((item) => {
const itemDiv = document.createElement('div');
itemDiv.className = 'text-xs text-gray-300';
const price = formatPrice(item.price, item.priceType, t);
itemDiv.textContent = `• ${item.title} - ${price}`;
itemsList.appendChild(itemDiv);
});
gearSection.appendChild(itemsList);
// View all gear button
const gearButton = document.createElement('button');
gearButton.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-medium bg-orange-500 text-white hover:bg-orange-600 h-7 px-3 transition-colors';
gearButton.textContent = marketplaceItems.length > 3 ? t('mapUi.viewAllItems', { count: marketplaceItems.length }) : t('mapUi.viewGear');
gearButton.onclick = (e) => {
e.preventDefault();
onViewDetails(); // Goes to spot page which has gear section
};
gearSection.appendChild(gearButton);
container.appendChild(gearSection);
}
return container;
}
export function SpotMap({
spots,
marketplaceItems = [],
tours = [],
events = [],
crews = [],
btcMapPlaces = [],
center = [40.7128, -74.006], // Default to NYC
zoom = 3,
onSpotClick,
selectedSpot,
onMapClick,
onMapMove,
enableClickToAdd = false,
className,
weatherEnabled = false,
weatherData,
showSpots = true,
showShops = true,
showGear = true,
showTours = true,
showEvents = false,
showCrews = false,
showBtcMap = false,
flyTo = null,
userLocation = null,
userPicture = null,
pendingPin = null,
}: SpotMapProps) {
const { t } = useTranslation();
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const markersRef = useRef<L.Marker[]>([]);
const btcMapMarkersRef = useRef<L.Marker[]>([]);
const userMarkerRef = useRef<L.Marker | null>(null);
const pendingPinMarkerRef = useRef<L.Marker | null>(null);
// MarkerClusterGroup for spot/shop/gear/tour markers
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const clusterGroupRef = useRef<any>(null);
// Signature of the marker set currently on the map — lets us skip a full
// rebuild when the parent hands us new arrays with identical content.
const markerSigRef = useRef<string>('');
const onMapMoveRef = useRef(onMapMove);
const navigate = useNavigate();
// Keep the ref updated with latest callback
useEffect(() => {
onMapMoveRef.current = onMapMove;
}, [onMapMove]);
// Group all entities by location (geohash-8 for ~40m grouping)
// This ensures nearby but distinct spots each get their own marker
const locationGroups = useMemo(() => {
const groups = new Map<string, {
geohash: string;
lat: number;
lng: number;
spots: SkateSpot[];
marketplaceItems: MarketplaceItem[];
}>();
// Add spots
if (showSpots || showShops) {
spots?.filter((spot) => spot.geohash).forEach((spot) => {
const key = spot.geohash.slice(0, 8);
const { latitude, longitude } = ngeohash.decode(spot.geohash);
if (!groups.has(key)) {
groups.set(key, {
geohash: key,
lat: latitude,
lng: longitude,
spots: [],
marketplaceItems: [],
});
}
const isShop = spot.types.includes('shop');
if ((isShop && showShops) || (!isShop && showSpots)) {
groups.get(key)!.spots.push(spot);
}
});
}
// Add marketplace items (but NOT at shop-only locations)
if (showGear) {
marketplaceItems?.filter((item) => item.geohash).forEach((item) => {
const key = item.geohash!.slice(0, 8);
if (!groups.has(key)) {
const { latitude, longitude } = ngeohash.decode(item.geohash!);
groups.set(key, {
geohash: key,
lat: latitude,
lng: longitude,
spots: [],
marketplaceItems: [],
});
}
const group = groups.get(key)!;
// IMPORTANT: Don't add gear to shop-only locations (conflict of interest)
// Only add gear if:
// 1. No spots at this location (standalone gear listing), OR
// 2. There's at least one regular (non-shop) spot at this location
const hasShopOnly = group.spots.length > 0 && group.spots.every(s => s.types.includes('shop'));
if (!hasShopOnly) {
group.marketplaceItems.push(item);
}
});
}
return Array.from(groups.values()).filter(
group => group.spots.length > 0 || group.marketplaceItems.length > 0
);
}, [spots, marketplaceItems, showSpots, showShops, showGear]);
const handleViewDetails = useCallback((spot: SkateSpot) => {
// Check if this is a shop by looking at types
const isShop = spot.types.includes('shop');
const route = isShop ? '/shop' : '/spot';
navigate(`${route}/${spot.pubkey}/${spot.identifier}`);
}, [navigate]);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
const map = L.map(mapRef.current, {
center,
zoom,
scrollWheelZoom: true,
attributionControl: false,
// Prevent zooming out past a single world view (no grey space, no tile
// duplication). The real limit is computed after mount by
// `applyWorldMinZoom()` because it depends on the viewport size.
minZoom: 1,
// Clamp vertically (no grey above/below the poles) but let the map wrap
// horizontally at the antimeridian, so the Pacific is reachable.
maxBounds: [[-85, -Infinity], [85, Infinity]],
maxBoundsViscosity: 1.0,
worldCopyJump: true,
});
applyWorldMinZoom(map);
// Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS
// filter (see the `.skatespots-dark-tiles` rule in index.css) so it matches
// the app's dark theme without depending on a keyed provider like CARTO.
// Wrapped in an on-device tile cache so the basemap still draws at the park.
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '',
subdomains: 'abc',
minZoom: 1,
maxZoom: 19,
className: 'skatespots-dark-tiles',
}).addTo(map);
// Add click handler if enabled
if (enableClickToAdd && onMapClick) {
map.on('click', (e) => {
onMapClick(e.latlng.lat, e.latlng.lng);
});
}
// Add moveend handler for BTC Map updates
map.on('moveend', () => {
const center = map.getCenter();
onMapMoveRef.current?.({ lat: center.lat, lon: center.lng, zoom: map.getZoom() });
});
mapInstanceRef.current = map;
// Create and add the marker cluster group for spot/shop/gear/tour markers.
// Clusters break apart naturally as the user zooms in, keeping the map
// clean even when hundreds of spots are close together.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const clusterGroup = (L as any).markerClusterGroup({
maxClusterRadius: 60, // px — tighter clustering for dense cities
showCoverageOnHover: false, // don't draw polygons on hover
spiderfyOnMaxZoom: true, // spread out overlapping pins at max zoom
removeOutsideVisibleBounds: true,
iconCreateFunction: (cluster: { getChildCount: () => number }) => {
const count = cluster.getChildCount();
return L.divIcon({
html: `
<div style="
width: 42px;
height: 42px;
background: linear-gradient(135deg, #ff0040, #ff6600);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 900;
font-size: ${count > 99 ? '11px' : count > 9 ? '13px' : '15px'};
box-shadow: 0 0 0 1px rgba(0,0,0,0.35), 0 0 12px rgba(255,0,64,0.9), 0 3px 8px rgba(0,0,0,0.45);
border: 3px solid #fff;
font-family: Impact, 'Arial Black', sans-serif;
letter-spacing: -0.5px;
">${count}</div>
`,
className: 'skate-cluster-icon',
iconSize: [42, 42],
iconAnchor: [21, 21],
});
},
});
clusterGroup.addTo(map);
clusterGroupRef.current = clusterGroup;
// Fix map size after initialization and ensure tiles are loaded
setTimeout(() => {
map.invalidateSize();
applyWorldMinZoom(map);
// Force a redraw to ensure everything is ready
map.whenReady(() => {
// Trigger initial map move event
const center = map.getCenter();
onMapMoveRef.current?.({ lat: center.lat, lon: center.lng, zoom: map.getZoom() });
});
}, 100);
return () => {
map.remove();
mapInstanceRef.current = null;
};
// Map init runs once on mount; center/zoom/click handlers are applied by
// later effects, so they must not re-create the Leaflet map.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Fix map size when container resizes
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || !mapRef.current) return;
const resizeObserver = new ResizeObserver(() => {
map.invalidateSize();
applyWorldMinZoom(map);
});
resizeObserver.observe(mapRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
// Update center when selectedSpot changes
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (selectedSpot && selectedSpot.geohash) {
const { latitude, longitude } = ngeohash.decode(selectedSpot.geohash);
map.setView([latitude, longitude], 15, { animate: true });
}
}, [selectedSpot]);
// Fly to location when flyTo prop changes
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || !flyTo) return;
map.flyTo([flyTo.lat, flyTo.lng], flyTo.zoom ?? 14, { animate: true, duration: 1.2 });
}, [flyTo]);
// Render / update the "you are here" user-location marker.
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (!userLocation) {
// No location — remove any existing marker.
if (userMarkerRef.current) {
userMarkerRef.current.remove();
userMarkerRef.current = null;
}
return;
}
if (userMarkerRef.current) {
// Move the existing marker to the new position and refresh its icon
// (the profile picture can arrive after the marker was first created).
userMarkerRef.current.setLatLng([userLocation.lat, userLocation.lng]);
userMarkerRef.current.setIcon(createUserLocationIcon(userPicture));
} else {
// Create it, on top of everything else.
userMarkerRef.current = L.marker([userLocation.lat, userLocation.lng], {
icon: createUserLocationIcon(userPicture),
zIndexOffset: 1000,
interactive: true,
keyboard: false,
})
.addTo(map)
.bindPopup(`<b>${escapeHtml(t('mapUi.youAreHere'))}</b>`);
}
}, [userLocation, userPicture, t]);
// Render / update the tap-to-add pending pin. The user taps the map, a pin
// drops here, and MapPage shows an "Add a spot here?" card — only on confirm
// do we leave the map. Without this a logged-in tap yanked you straight into
// the create form, which made the map feel broken.
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
if (!pendingPin) {
if (pendingPinMarkerRef.current) {
pendingPinMarkerRef.current.remove();
pendingPinMarkerRef.current = null;
}
return;
}
const latlng: [number, number] = [pendingPin.lat, pendingPin.lng];
if (pendingPinMarkerRef.current) {
pendingPinMarkerRef.current.setLatLng(latlng);
} else {
pendingPinMarkerRef.current = L.marker(latlng, {
icon: L.divIcon({
className: '',
html:
'<div style="display:flex;flex-direction:column;align-items:center;transform:translateY(-4px)">' +
'<div style="width:22px;height:22px;border-radius:50%;background:#ff0040;border:3px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.55)"></div>' +
'<div style="width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-top:11px solid #fff;margin-top:-1px"></div>' +
'</div>',
iconSize: [22, 34],
iconAnchor: [11, 34],
}),
zIndexOffset: 1200,
keyboard: false,
}).addTo(map);
}
map.panTo(latlng, { animate: true });
}, [pendingPin]);
// Update markers
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
// Wait for map to be fully loaded before adding markers
if (!map.getContainer()) return;
// Skip the whole rebuild when nothing that affects the rendered markers has
// actually changed. Every spot/listing/tour/event/crew is an addressable
// event, so a new event id means an edit — ids + weather colours are enough
// to detect real changes. Without this, the unrelated identity churn the
// parent hands us (trust scores, comment/zap totals and re-sorts all produce
// fresh arrays) tore down and rebuilt every Leaflet marker several times per
// load, which is what made the pins feel slow.
const markerSig = [
`${showSpots}|${showShops}|${showGear}|${showTours}|${showEvents}|${showCrews}|${weatherEnabled}`,
locationGroups
.map((group) => {
const first = group.spots[0];
const weather =
weatherEnabled && weatherData && first?.geohash
? weatherData.get(first.geohash)
: undefined;
return [
group.geohash,
group.spots.map((s) => s.event.id).join('.'),
group.marketplaceItems.map((i) => i.event.id).join('.'),
weather?.skateability.color ?? '',
weather?.weather.weatherIcon ?? '',
].join(':');
})
.join('|'),
tours.map((t) => t.id).join('.'),
events.map((e) => e.id).join('.'),
crews.map((c) => c.id).join('.'),
].join('~~');
if (markerSig === markerSigRef.current) return;
markerSigRef.current = markerSig;
// Clear existing markers from cluster group and array
if (clusterGroupRef.current) {
clusterGroupRef.current.clearLayers();
}
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
// Markers built this pass; added to the cluster group in batches below.
const pending: L.Marker[] = [];
// Ensure we have location data before adding markers
if (locationGroups.length === 0) return;
// Add new markers for each location group
locationGroups.forEach((group) => {
const hasSpots = group.spots.length > 0;
// Determine primary spot (for popup)
const primarySpot = group.spots[0];
// Check if primary spot is a shop
const isShop = primarySpot?.types.includes('shop');
// Check if location has BOTH shop type AND other skate spot types (like park, street, etc.)
const hasNonShopTypes = primarySpot?.types.some(t => t !== 'shop');
// IMPORTANT: Don't show gear at shop-only locations (conflict of interest)
// Only show gear at regular skate spots or dual shop+spot locations
const canShowGear = hasNonShopTypes || !isShop;
const hasGear = canShowGear && group.marketplaceItems.length > 0;
const gearCount = hasGear ? group.marketplaceItems.length : 0;
// Determine icon based on what's at this location
let icon: L.DivIcon;
if (isShop && hasNonShopTypes) {
// Location has shop type AND other spot types (e.g., park, street) - use dual icon
icon = createDualIcon(hasGear, gearCount);
} else if (isShop) {
// Shop only - just shop type, no other spot types
icon = createShopIcon();
} else if (hasSpots && !hasGear) {
// Regular spot only
const spotWeather = weatherEnabled && weatherData && primarySpot ? weatherData.get(primarySpot.geohash) : undefined;
icon = createSkateSpotIcon({
weatherColor: spotWeather?.skateability.color,
weatherIcon: spotWeather?.weather.weatherIcon,
shelter: primarySpot?.shelter,
});
} else if (!hasSpots && hasGear) {
// Gear only (no spot)
icon = createMarketplaceIcon(gearCount);
} else {
// Spot + gear - regular spot icon
const spotWeather = weatherEnabled && weatherData && primarySpot ? weatherData.get(primarySpot.geohash) : undefined;
icon = createSkateSpotIcon({
weatherColor: spotWeather?.skateability.color,
weatherIcon: spotWeather?.weather.weatherIcon,
shelter: primarySpot?.shelter,
});
}
const marker = L.marker([group.lat, group.lng], {
icon,
});
// When the host page supplies onSpotClick (MapPage's bottom sheet), let it
// own the detail UI — binding the Leaflet popup too made BOTH appear at
// once. Standalone map usages (no handler) keep the popup.
if (primarySpot) {
if (!onSpotClick) {
const spotWeather = weatherEnabled && weatherData ? weatherData.get(primarySpot.geohash) : undefined;
// Use enhanced popup if there are marketplace items, otherwise use standard
const popupContent = hasGear
? createEnhancedPopupContent(primarySpot, group.marketplaceItems, () => handleViewDetails(primarySpot), t)
: createPopupContent(primarySpot, () => handleViewDetails(primarySpot), spotWeather, t);
marker.bindPopup(popupContent, {
maxWidth: 350,
className: 'custom-popup',
});
}
marker.on('click', () => {
onSpotClick?.(primarySpot);
});
} else if (hasGear && !hasSpots) {
// Gear-only location (no spot) - create simple gear popup
const container = document.createElement('div');
container.className = 'p-2 min-w-[200px]';
const title = document.createElement('h3');
title.className = 'font-bold text-sm text-orange-400 mb-2';
title.textContent = `🛒 ${t('mapUi.gearForSale', { count: gearCount }).toUpperCase()}`;
container.appendChild(title);
const itemsList = document.createElement('div');
itemsList.className = 'space-y-1 mb-3';
group.marketplaceItems.slice(0, 3).forEach((item) => {
const itemDiv = document.createElement('div');
itemDiv.className = 'text-xs text-gray-300';
const price = formatPrice(item.price, item.priceType, t);
itemDiv.textContent = `• ${item.title} - ${price}`;
itemsList.appendChild(itemDiv);
});
container.appendChild(itemsList);
const button = document.createElement('button');
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-medium bg-orange-500 text-white hover:bg-orange-600 h-7 px-3 transition-colors';
button.textContent = t('mapUi.viewItems');
button.onclick = (e) => {
e.preventDefault();
// Navigate to marketplace or first item's linked spot
const firstItem = group.marketplaceItems[0];
if (firstItem?.spotReference) {
const [, pubkey, identifier] = firstItem.spotReference.split(':');
navigate(`/spot/${pubkey}/${identifier}`);
} else {
navigate('/marketplace');
}
};
container.appendChild(button);
marker.bindPopup(container, {
maxWidth: 300,
className: 'custom-popup',
});
}
// Queue for a batched add to the cluster group (see flush below).
pending.push(marker);
markersRef.current.push(marker);
});
// Add tour markers (separate from spot/shop markers)
if (showTours && tours.length > 0) {
tours.forEach((tour) => {
// Use meeting point coords if available, otherwise decode geohash
let lat: number, lng: number;
if (tour.meetingPointCoords) {
lat = tour.meetingPointCoords.lat;
lng = tour.meetingPointCoords.lon;
} else if (tour.geohash) {
const decoded = ngeohash.decode(tour.geohash);
lat = decoded.latitude;
lng = decoded.longitude;
} else {
return; // Skip tours without location
}
const tourMarker = L.marker([lat, lng], {
icon: createTourIcon(),
});
// Create tour popup
const container = document.createElement('div');
container.className = 'p-3 min-w-[250px] max-w-[300px]';
// Tour header — icon block is static; tour.title is UNTRUSTED (textContent).
const header = document.createElement('div');
header.className = 'flex items-start gap-2 mb-3';
const tourIconBox = document.createElement('div');
tourIconBox.className = 'w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center flex-shrink-0';
tourIconBox.innerHTML = '<span class="text-lg">🛹</span>';
const tourHeadText = document.createElement('div');
tourHeadText.className = 'flex-1 min-w-0';
const tourTitle = document.createElement('h3');
tourTitle.className = 'font-bold text-base text-white leading-tight';
tourTitle.textContent = tour.title;
const tourKicker = document.createElement('p');
tourKicker.className = 'text-xs text-cyan-400 font-medium';
tourKicker.textContent = t('mapUi.localTour').toUpperCase();
tourHeadText.appendChild(tourTitle);
tourHeadText.appendChild(tourKicker);
header.appendChild(tourIconBox);
header.appendChild(tourHeadText);
container.appendChild(header);
// Tour details — untrusted strings go through popupRow (textContent).
const details = document.createElement('div');
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
if (tour.location) {
details.appendChild(popupRow('📍', tour.location));
}
if (tour.meetingPoint) {
details.appendChild(popupRow('🚩', t('mapUi.meetAt', { place: tour.meetingPoint })));
}
if (tour.duration) {
details.appendChild(popupRow('⏱️', t('mapUi.hours', { count: tour.duration })));
}
if (tour.skillLevel) {
const skillEmoji = tour.skillLevel === 'beginner' ? '🌱' : tour.skillLevel === 'intermediate' ? '🔥' : tour.skillLevel === 'advanced' ? '💀' : '🛹';
details.appendChild(popupRow(skillEmoji, t('mapUi.level', { level: tour.skillLevel.charAt(0).toUpperCase() + tour.skillLevel.slice(1) })));
}
if (tour.price) {
details.appendChild(popupRow('⚡', `${tour.price.toLocaleString()} ${t('common.sats')}`));
}
container.appendChild(details);
// Spot types badges
if (tour.spotTypes.length > 0) {
const badges = document.createElement('div');
badges.className = 'flex flex-wrap gap-1 mb-3';
tour.spotTypes.slice(0, 4).forEach((type) => {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30';
badge.textContent = type;
badges.appendChild(badge);
});
container.appendChild(badges);
}
// View button
const button = document.createElement('button');
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-cyan-500 text-white hover:bg-cyan-600 h-8 px-3 transition-colors';
button.textContent = t('mapUi.viewTour');
button.onclick = (e) => {
e.preventDefault();
navigate(`/tour/${tour.pubkey}/${tour.identifier}`);
};
container.appendChild(button);
tourMarker.bindPopup(container, {
maxWidth: 320,
className: 'custom-popup tour-popup',
});
// Queue for a batched add to the cluster group (see flush below).
pending.push(tourMarker);
markersRef.current.push(tourMarker);
});
}
// Add event markers (upcoming, geo-located events — lime pins)
if (showEvents && events.length > 0) {
const nowSec = Math.floor(Date.now() / 1000);
events.forEach((evt) => {
// Only plot events with a valid geohash that haven't ended yet
if (!evt.geohash) return;
if ((evt.end ?? evt.start) < nowSec) return;
const decoded = ngeohash.decode(evt.geohash);
const eventMarker = L.marker([decoded.latitude, decoded.longitude], {
icon: createEventIcon(),
});
const container = document.createElement('div');
container.className = 'p-3 min-w-[240px] max-w-[300px]';
// Header — icon block is static; evt.title is UNTRUSTED (textContent).
const header = document.createElement('div');
header.className = 'flex items-start gap-2 mb-3';
const evtIconBox = document.createElement('div');
evtIconBox.className = 'w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0';
evtIconBox.innerHTML = '<span class="text-lg">📅</span>';
const evtHeadText = document.createElement('div');
evtHeadText.className = 'flex-1 min-w-0';
const evtTitle = document.createElement('h3');
evtTitle.className = 'font-bold text-base text-white leading-tight';
evtTitle.textContent = evt.title;
const evtKicker = document.createElement('p');
evtKicker.className = 'text-xs text-[#a3cc00] font-medium';
evtKicker.textContent = t('mapUi.upcomingEvent').toUpperCase();
evtHeadText.appendChild(evtTitle);
evtHeadText.appendChild(evtKicker);
header.appendChild(evtIconBox);
header.appendChild(evtHeadText);
container.appendChild(header);
// Details — date is app-generated; evt.location is UNTRUSTED (textContent).
const details = document.createElement('div');
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
const startDate = new Date(evt.start * 1000);
const dateStr = startDate.toLocaleDateString(dateLocaleCode(), { weekday: 'short', month: 'short', day: 'numeric' });
const timeStr = startDate.toLocaleTimeString(dateLocaleCode(), { hour: 'numeric', minute: '2-digit' });
details.appendChild(popupRow('🗓️', `${dateStr} · ${timeStr}`));
if (evt.location) {
details.appendChild(popupRow('📍', evt.location));
}
container.appendChild(details);
// View button
const button = document.createElement('button');
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-[#ccff00] text-black hover:bg-[#ccff00]/90 h-8 px-3 transition-colors';
button.textContent = t('mapUi.viewEvent');
button.onclick = (e) => {
e.preventDefault();
navigate(`/event/${evt.pubkey}/${evt.identifier}`);
};
container.appendChild(button);
eventMarker.bindPopup(container, {
maxWidth: 320,
className: 'custom-popup event-popup',
});
// Queue for a batched add to the cluster group (see flush below).
pending.push(eventMarker);
markersRef.current.push(eventMarker);
});
}
// Add crew markers (crew home turf — teal pins)
if (showCrews && crews.length > 0) {
crews.forEach((crew) => {
if (!crew.geohash) return; // Only plot crews with a home turf
const decoded = ngeohash.decode(crew.geohash);
const crewMarker = L.marker([decoded.latitude, decoded.longitude], {
icon: createCrewIcon(crew.image),
});
const container = document.createElement('div');
container.className = 'p-3 min-w-[240px] max-w-[300px]';
// Header — crew.image + crew.name are UNTRUSTED.
const header = document.createElement('div');
header.className = 'flex items-start gap-2 mb-3';
const crewIconBox = document.createElement('div');
crewIconBox.className = 'w-10 h-10 rounded-lg bg-teal-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden';
const safeCrewImg = sanitizeUrl(crew.image);
if (safeCrewImg) {
const img = document.createElement('img');
img.src = safeCrewImg; // validated http(s) URL
img.className = 'w-full h-full object-cover';
// Fallback to emoji on load error — no inline handler / no innerHTML.
img.onerror = () => {
crewIconBox.textContent = '';
const fallback = document.createElement('span');
fallback.className = 'text-lg';
fallback.textContent = '🤘';
crewIconBox.appendChild(fallback);
};
crewIconBox.appendChild(img);
} else {
const emoji = document.createElement('span');
emoji.className = 'text-lg';
emoji.textContent = '🤘';
crewIconBox.appendChild(emoji);
}
const crewHeadText = document.createElement('div');
crewHeadText.className = 'flex-1 min-w-0';
const crewName = document.createElement('h3');
crewName.className = 'font-bold text-base text-white leading-tight';
crewName.textContent = crew.name;
const crewKicker = document.createElement('p');
crewKicker.className = 'text-xs text-teal-400 font-medium';
crewKicker.textContent = t('mapUi.skateCrew').toUpperCase();
crewHeadText.appendChild(crewName);
crewHeadText.appendChild(crewKicker);
header.appendChild(crewIconBox);
header.appendChild(crewHeadText);
container.appendChild(header);
// Details — crew.location is UNTRUSTED; counts/founded via popupRow too.
const details = document.createElement('div');
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
if (crew.location) {
details.appendChild(popupRow('📍', crew.location));
}
if (crew.members.length > 0) {
details.appendChild(popupRow('👥', t('mapUi.members', { count: crew.members.length })));
}
if (crew.founded) {
details.appendChild(popupRow('📅', t('mapUi.since', { year: crew.founded })));
}
container.appendChild(details);
// Vibe tags
if (crew.vibes.length > 0) {
const badges = document.createElement('div');
badges.className = 'flex flex-wrap gap-1 mb-3';
crew.vibes.slice(0, 4).forEach((vibe) => {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-teal-500/20 text-teal-300 border border-teal-500/30';
badge.textContent = vibe;
badges.appendChild(badge);
});
container.appendChild(badges);
}
// View button
const button = document.createElement('button');
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-teal-500 text-white hover:bg-teal-600 h-8 px-3 transition-colors';
button.textContent = t('mapUi.viewCrew');
button.onclick = (e) => {
e.preventDefault();
navigate(`/crew/${crew.pubkey}/${crew.identifier}`);
};
container.appendChild(button);
crewMarker.bindPopup(container, {
maxWidth: 320,
className: 'custom-popup crew-popup',
});
// Queue for a batched add to the cluster group (see flush below).
pending.push(crewMarker);
markersRef.current.push(crewMarker);
});
}
// Add the queued markers to the cluster group in batches across animation
// frames. `addLayers` is the plugin's bulk API — it clusters the batch and
// refreshes once, instead of the old one-marker-at-a-time `addLayer`, which
// re-clustered the whole set for every single pin. Batching keeps the main
// thread responsive (no freeze on a few hundred pins) and lets nearby pins
// paint first; cancel it if the effect re-runs before it finishes.
const group = clusterGroupRef.current;
if (!group) {
pending.forEach((marker) => marker.addTo(map));
} else if (pending.length > 0) {
const CHUNK_SIZE = 150;
let offset = 0;
let cancelled = false;
let raf = 0;
const flush = () => {
if (cancelled || !clusterGroupRef.current) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(clusterGroupRef.current as any).addLayers(pending.slice(offset, offset + CHUNK_SIZE));
offset += CHUNK_SIZE;
if (offset < pending.length) {
raf = requestAnimationFrame(flush);
}
};
flush();
return () => {
cancelled = true;
if (raf) cancelAnimationFrame(raf);
};
}
}, [locationGroups, tours, showTours, events, showEvents, crews, showCrews, showSpots, showShops, showGear, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate, t]);
// Update BTC Map markers
useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;
// Clear existing BTC Map markers
btcMapMarkersRef.current.forEach((marker) => marker.remove());
btcMapMarkersRef.current = [];
// Don't add markers if BTC Map is disabled
if (!showBtcMap || btcMapPlaces.length === 0) return;
btcMapPlaces.forEach((place) => {
const boosted = isBoosted(place.boosted_until);
const recentlyVerified = isRecentlyVerified(place.verified_at);
const marker = L.marker([place.lat, place.lon], {
icon: createBtcMapIcon(boosted),
});
// Create popup content
const container = document.createElement('div');
container.className = 'p-3 min-w-[220px] max-w-[300px]';
// Header with Bitcoin icon — place.name (from BTC Map API) is UNTRUSTED.
const header = document.createElement('div');
header.className = 'flex items-start gap-2 mb-2';
const btcIconBox = document.createElement('div');
btcIconBox.className = 'w-8 h-8 rounded-lg bg-[#f7931a]/20 flex items-center justify-center flex-shrink-0';
btcIconBox.innerHTML = '<span class="text-lg">₿</span>';
const btcHeadText = document.createElement('div');
btcHeadText.className = 'flex-1 min-w-0';
const btcName = document.createElement('h3');
btcName.className = 'font-bold text-sm text-white leading-tight';
btcName.textContent = place.name;
const btcKicker = document.createElement('p');
btcKicker.className = 'text-[10px] text-[#f7931a] font-medium uppercase';
btcKicker.textContent = t('mapUi.acceptsBitcoin');
btcHeadText.appendChild(btcName);
btcHeadText.appendChild(btcKicker);
header.appendChild(btcIconBox);
header.appendChild(btcHeadText);
container.appendChild(header);
// Badges row — static markup only (no untrusted data).
const badges = document.createElement('div');
badges.className = 'flex flex-wrap gap-1 mb-2';
if (boosted) {
badges.innerHTML += `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-yellow-500/20 text-yellow-300 border border-yellow-500/30">⭐ ${escapeHtml(t('mapUi.boosted'))}</span>`;
}
if (recentlyVerified) {
badges.innerHTML += `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-green-500/20 text-green-300 border border-green-500/30">✓ ${escapeHtml(t('common.verified'))}</span>`;
}
if (badges.innerHTML) {
container.appendChild(badges);
}
// Details — every place field is UNTRUSTED (textContent / sanitized tel).
const details = document.createElement('div');
details.className = 'space-y-1 mb-3 text-xs text-gray-300';
if (place.address) {
details.appendChild(popupRow('📍', place.address));
}
if (place.opening_hours) {
details.appendChild(popupRow('🕐', place.opening_hours));
}
if (place.phone) {
const phoneRow = document.createElement('div');
phoneRow.className = 'flex items-start gap-1.5';
const phoneIcon = document.createElement('span');
phoneIcon.textContent = '📞';
const phoneLink = document.createElement('a');
// tel: sanitized to digits/+/-/()/space only, then set via textContent.
const digits = place.phone.replace(/[^0-9+\-\s().]/g, '');
phoneLink.href = `tel:${digits}`;
phoneLink.className = 'text-[#f7931a] hover:underline';
phoneLink.textContent = place.phone;
phoneRow.appendChild(phoneIcon);
phoneRow.appendChild(phoneLink);
details.appendChild(phoneRow);
}
if (place.description) {
const desc = document.createElement('div');
desc.className = 'text-gray-400 text-[11px] mt-2 line-clamp-2';
desc.textContent = place.description;
details.appendChild(desc);
}
container.appendChild(details);
// Action buttons
const actions = document.createElement('div');
actions.className = 'flex gap-2';
// place.website is UNTRUSTED — only render the link for valid http(s) URLs.
const safeWebsite = sanitizeUrl(place.website);
if (safeWebsite) {
const websiteBtn = document.createElement('a');
websiteBtn.href = safeWebsite;
websiteBtn.target = '_blank';
websiteBtn.rel = 'noopener noreferrer';
websiteBtn.className = 'flex-1 inline-flex items-center justify-center rounded-md text-xs font-medium bg-[#f7931a] text-white hover:bg-[#f7931a]/90 h-7 px-2 transition-colors';
websiteBtn.textContent = t('mapUi.website');
actions.appendChild(websiteBtn);
}
const btcMapBtn = document.createElement('a');
btcMapBtn.href = `https://btcmap.org/merchant/${place.id}`;
btcMapBtn.target = '_blank';
btcMapBtn.rel = 'noopener noreferrer';
btcMapBtn.className = `${safeWebsite ? '' : 'flex-1 '}inline-flex items-center justify-center rounded-md text-xs font-medium bg-gray-700 text-white hover:bg-gray-600 h-7 px-2 transition-colors`;
btcMapBtn.textContent = 'BTC Map →';
actions.appendChild(btcMapBtn);
container.appendChild(actions);
marker.bindPopup(container, {
maxWidth: 320,
className: 'custom-popup btcmap-popup',
});
marker.addTo(map);
btcMapMarkersRef.current.push(marker);
});
}, [btcMapPlaces, showBtcMap, t]);
return (
<div className={className}>
<div className="relative h-full w-full">
<div ref={mapRef} className="h-full w-full rounded-xl" />
</div>
</div>
);
}