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> </div>
))} ))}
{/* Centered, quiet status label */} {/* Quiet status label — slim pill near the TOP so it never blocks the
<div className="absolute left-1/2 top-[46%] -translate-x-1/2 -translate-y-1/2"> map's center where the spots are. */}
<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"> <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="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="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]" /> <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; L.Marker.prototype.options.icon = defaultIcon;
// Custom skate spot marker icon (for regular spots) /** Emoji glyph per spot category — turns the map into a readable language. */
const createSkateSpotIcon = (weatherColor?: string, weatherIcon?: string) => { const CATEGORY_GLYPH: Record<string, string> = {
const gradient = weatherColor 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: ${weatherColor};`
: 'background: linear-gradient(135deg, #ff0040, #ff6600);'; : '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({ return L.divIcon({
className: 'skate-spot-marker', className: 'skate-spot-marker',
html: ` html: `
<div style=" <div class="ss-pin" style="position: relative; width: 36px; height: 36px;">
width: 36px; ${roofCap}
height: 36px; <div style="
${gradient} width: 36px;
border-radius: 50% 50% 50% 0; height: 36px;
transform: rotate(-45deg); ${fill}
display: flex; border-radius: 50% 50% 50% 0;
align-items: center; transform: rotate(-45deg);
justify-content: center; display: flex;
box-shadow: 0 4px 12px rgba(255,0,64,0.5); align-items: center;
border: 3px solid ${borderColor}; justify-content: center;
position: relative; 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);
${weatherIcon ? ` position: relative;
<div style=" ">
position: absolute; ${weatherIcon ? `
top: -8px; <div style="
right: -8px; position: absolute;
background: white; top: -8px;
border-radius: 50%; right: -8px;
width: 20px; background: white;
height: 20px; border-radius: 50%;
display: flex; width: 20px;
align-items: center; height: 20px;
justify-content: center; display: flex;
font-size: 12px; align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2); 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); transform: rotate(45deg);
">${weatherIcon}</div> font-size: 16px;
` : ''} line-height: 1;
<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);"> filter: drop-shadow(0 1px 1px rgba(0,0,0,0.5));
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/> ">${glyph}</span>
<circle cx="12" cy="10" r="3"/> </div>
</svg>
</div> </div>
`, `,
iconSize: [36, 36], iconSize: [36, 36],
@ -902,8 +951,8 @@ export function SpotMap({
color: white; color: white;
font-weight: 900; font-weight: 900;
font-size: ${count > 99 ? '11px' : count > 9 ? '13px' : '15px'}; font-size: ${count > 99 ? '11px' : count > 9 ? '13px' : '15px'};
box-shadow: 0 4px 14px rgba(255,0,64,0.55); 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 rgba(255,255,255,0.85); border: 3px solid #fff;
font-family: Impact, 'Arial Black', sans-serif; font-family: Impact, 'Arial Black', sans-serif;
letter-spacing: -0.5px; letter-spacing: -0.5px;
">${count}</div> ">${count}</div>
@ -1046,22 +1095,24 @@ export function SpotMap({
} else if (hasSpots && !hasGear) { } else if (hasSpots && !hasGear) {
// Regular spot only // Regular spot only
const spotWeather = weatherEnabled && weatherData && primarySpot ? weatherData.get(primarySpot.geohash) : undefined; const spotWeather = weatherEnabled && weatherData && primarySpot ? weatherData.get(primarySpot.geohash) : undefined;
if (spotWeather) { icon = createSkateSpotIcon({
icon = createSkateSpotIcon(spotWeather.skateability.color, spotWeather.weather.weatherIcon); weatherColor: spotWeather?.skateability.color,
} else { weatherIcon: spotWeather?.weather.weatherIcon,
icon = createSkateSpotIcon(); category: primarySpot?.category,
} shelter: primarySpot?.shelter,
});
} else if (!hasSpots && hasGear) { } else if (!hasSpots && hasGear) {
// Gear only (no spot) // Gear only (no spot)
icon = createMarketplaceIcon(gearCount); icon = createMarketplaceIcon(gearCount);
} else { } else {
// Spot + gear - regular spot icon // Spot + gear - regular spot icon
const spotWeather = weatherEnabled && weatherData && primarySpot ? weatherData.get(primarySpot.geohash) : undefined; const spotWeather = weatherEnabled && weatherData && primarySpot ? weatherData.get(primarySpot.geohash) : undefined;
if (spotWeather) { icon = createSkateSpotIcon({
icon = createSkateSpotIcon(spotWeather.skateability.color, spotWeather.weather.weatherIcon); weatherColor: spotWeather?.skateability.color,
} else { weatherIcon: spotWeather?.weather.weatherIcon,
icon = createSkateSpotIcon(); category: primarySpot?.category,
} shelter: primarySpot?.shelter,
});
} }
const marker = L.marker([group.lat, group.lng], { const marker = L.marker([group.lat, group.lng], {

View file

@ -360,6 +360,41 @@
animation: ghostPing 2.4s ease-in-out infinite; 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 { .animate-fadeIn {
animation: fadeIn 0.4s ease-out forwards; animation: fadeIn 0.4s ease-out forwards;
} }

View file

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