Map Pass 1: crisp pins, spot-type & shelter glyphs, floating loading, micro-interactions

Visual foundation for the map (ideas #2, #3, #4, #6 — features #5/#7 intentionally
deferred to a later pass):

#2 Crisp pins, not blobs:
- Reworked createSkateSpotIcon — solid neon teardrop with a TIGHT glow halo
  (inset dark ring + focused shadow) instead of the soft box-shadow that read as
  a fuzzy cloud on light tiles. Cluster badges given the same crisp treatment.

#3 Marker = spot type (a readable language):
- Category emoji inside each pin: 🛹 street / 🏞️ park / 🔧 DIY / 🏛️ plaza / 🌊 transition.
- Covered/indoor spots get a cyan roof "cap" so dry spots stand out at a glance —
  the payoff of the shelter data living on the map, not just a badge.
- createSkateSpotIcon now takes an options object (weatherColor/weatherIcon/
  category/shelter); all call sites updated. Weather-layer colouring unchanged.

#4 Less scrim, floating loading:
- Suspense map-load fallback dimming reduced (bg-black/50 → /20).
- "Finding spots" pill moved from dead-centre (top 46%) to a slim top pill so it
  never blocks the map where the spots are.

#6 Micro-interactions (CSS, GPU-friendly, reduced-motion aware):
- New pins drop in with a spring; hover/focus/tap lifts the pin and pulses its
  neon halo once.

Build clean, no console errors. No tile/theme change — the "og" light map is
untouched per request.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-10 18:46:15 +07:00
parent a7b7e07e86
commit 345af97788
4 changed files with 140 additions and 53 deletions

View file

@ -68,9 +68,10 @@ export function MapSkeletonPins() {
</div>
))}
{/* Centered, quiet status label */}
<div className="absolute left-1/2 top-[46%] -translate-x-1/2 -translate-y-1/2">
<div className="flex items-center gap-2 rounded-full bg-black/45 px-3.5 py-1.5 backdrop-blur-sm ring-1 ring-white/10">
{/* Quiet status label — slim pill near the TOP so it never blocks the
map's center where the spots are. */}
<div className="absolute left-1/2 top-3 -translate-x-1/2">
<div className="flex items-center gap-2 rounded-full bg-black/55 px-3.5 py-1.5 backdrop-blur-md ring-1 ring-white/10 shadow-lg">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full rounded-full bg-[#ff0040] opacity-70 motion-safe:animate-ping" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#ff0040]" />

View file

@ -63,51 +63,100 @@ const defaultIcon = L.icon({
L.Marker.prototype.options.icon = defaultIcon;
// Custom skate spot marker icon (for regular spots)
const createSkateSpotIcon = (weatherColor?: string, weatherIcon?: string) => {
const gradient = weatherColor
/** Emoji glyph per spot category — turns the map into a readable language. */
const CATEGORY_GLYPH: Record<string, string> = {
street: '🛹',
park: '🏞️',
diy: '🔧',
plaza: '🏛️',
transition: '🌊',
};
/**
* Crisp skate-spot marker: a solid neon teardrop with a TIGHT glow halo (the
* glow is an accent, not the whole blob), a category emoji inside so the pin is
* readable at a glance, 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;
category?: string;
shelter?: 'outdoor' | 'partial' | 'covered' | 'indoor';
}) => {
const { weatherColor, weatherIcon, category, shelter } = opts ?? {};
const fill = weatherColor
? `background: ${weatherColor};`
: 'background: linear-gradient(135deg, #ff0040, #ff6600);';
const glowColor = weatherColor || '#ff0040';
const glyph = (category && CATEGORY_GLYPH[category]) || '🛹';
const isSheltered = shelter === 'covered' || shelter === 'indoor';
const borderColor = weatherColor ? weatherColor : '#fff';
// 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 style="
width: 36px;
height: 36px;
${gradient}
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255,0,64,0.5);
border: 3px solid ${borderColor};
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.2);
<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);
">${weatherIcon}</div>
` : ''}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
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],
@ -902,8 +951,8 @@ export function SpotMap({
color: white;
font-weight: 900;
font-size: ${count > 99 ? '11px' : count > 9 ? '13px' : '15px'};
box-shadow: 0 4px 14px rgba(255,0,64,0.55);
border: 3px solid rgba(255,255,255,0.85);
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>
@ -1046,22 +1095,24 @@ export function SpotMap({
} else if (hasSpots && !hasGear) {
// Regular spot only
const spotWeather = weatherEnabled && weatherData && primarySpot ? weatherData.get(primarySpot.geohash) : undefined;
if (spotWeather) {
icon = createSkateSpotIcon(spotWeather.skateability.color, spotWeather.weather.weatherIcon);
} else {
icon = createSkateSpotIcon();
}
icon = createSkateSpotIcon({
weatherColor: spotWeather?.skateability.color,
weatherIcon: spotWeather?.weather.weatherIcon,
category: primarySpot?.category,
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;
if (spotWeather) {
icon = createSkateSpotIcon(spotWeather.skateability.color, spotWeather.weather.weatherIcon);
} else {
icon = createSkateSpotIcon();
}
icon = createSkateSpotIcon({
weatherColor: spotWeather?.skateability.color,
weatherIcon: spotWeather?.weather.weatherIcon,
category: primarySpot?.category,
shelter: primarySpot?.shelter,
});
}
const marker = L.marker([group.lat, group.lng], {

View file

@ -360,6 +360,41 @@
animation: ghostPing 2.4s ease-in-out infinite;
}
/* ── Live map markers: crisp pins that feel alive ─────────────────────────────
A new pin drops in with a quick spring; hovering/tapping lifts it and pulses
its neon halo once. Purely presentational, GPU-friendly (transform/opacity),
and fully disabled under reduced-motion. */
@keyframes ssPinDrop {
0% { transform: translateY(-14px) scale(0.6); opacity: 0; }
60% { transform: translateY(2px) scale(1.06); opacity: 1; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes ssPinPulse {
0% { filter: drop-shadow(0 0 2px rgba(255,0,64,0.0)); }
50% { filter: drop-shadow(0 0 10px rgba(255,0,64,0.9)); }
100% { filter: drop-shadow(0 0 2px rgba(255,0,64,0.0)); }
}
/* The teardrop wrapper inside our divIcon. */
.skate-spot-marker .ss-pin {
transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: 50% 100%;
will-change: transform;
}
@media (prefers-reduced-motion: no-preference) {
.skate-spot-marker .ss-pin {
animation: ssPinDrop 0.32s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* Lift + one halo pulse on hover/focus/press. Leaflet keeps the marker
focusable, so this also covers keyboard + tap. */
.skate-spot-marker:hover .ss-pin,
.skate-spot-marker:focus .ss-pin,
.skate-spot-marker:active .ss-pin {
transform: translateY(-4px) scale(1.12);
animation: ssPinPulse 0.6s ease-out;
}
}
.animate-fadeIn {
animation: fadeIn 0.4s ease-out forwards;
}

View file

@ -1580,7 +1580,7 @@ export default function MapPage() {
{viewMode === 'map' ? (
<>
<Suspense fallback={
<div className="h-full bg-black/50 flex items-center justify-center">
<div className="h-full bg-black/20 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-white font-bold">Loading map...</span>