+ {/* 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' ? (
<>
+