From 345af977889bd1b616b3a43afcf323c78640664b Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 10 Sep 2026 18:46:15 +0700 Subject: [PATCH] Map Pass 1: crisp pins, spot-type & shelter glyphs, floating loading, micro-interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/MapSkeletonPins.tsx | 7 +- src/components/SpotMap.tsx | 149 +++++++++++++++++++---------- src/index.css | 35 +++++++ src/pages/MapPage.tsx | 2 +- 4 files changed, 140 insertions(+), 53 deletions(-) diff --git a/src/components/MapSkeletonPins.tsx b/src/components/MapSkeletonPins.tsx index 0c36826..57b7b64 100644 --- a/src/components/MapSkeletonPins.tsx +++ b/src/components/MapSkeletonPins.tsx @@ -68,9 +68,10 @@ export function MapSkeletonPins() { ))} - {/* Centered, quiet status label */} -
-
+ {/* Quiet status label — slim pill near the TOP so it never blocks the + map's center where the spots are. */} +
+
diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx index 7cb0ef3..ac0a0e2 100644 --- a/src/components/SpotMap.tsx +++ b/src/components/SpotMap.tsx @@ -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 = { + 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 + ? ` +
` + : ''; return L.divIcon({ className: 'skate-spot-marker', html: ` -
- ${weatherIcon ? ` -
+ ${roofCap} +
+ ${weatherIcon ? ` +
${weatherIcon}
+ ` : ''} + ${weatherIcon}
- ` : ''} - - - - + font-size: 16px; + line-height: 1; + filter: drop-shadow(0 1px 1px rgba(0,0,0,0.5)); + ">${glyph} +
`, 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}
@@ -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], { diff --git a/src/index.css b/src/index.css index 3406dcc..b2e925b 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index ee0e302..50f0e6f 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -1580,7 +1580,7 @@ export default function MapPage() { {viewMode === 'map' ? ( <> +
Loading map...