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:
parent
a7b7e07e86
commit
345af97788
4 changed files with 140 additions and 53 deletions
|
|
@ -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]" />
|
||||||
|
|
|
||||||
|
|
@ -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], {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue