diff --git a/src/components/map/energy-map.tsx b/src/components/map/energy-map.tsx index 48cb5e8..b29204a 100644 --- a/src/components/map/energy-map.tsx +++ b/src/components/map/energy-map.tsx @@ -65,7 +65,7 @@ export function EnergyMap({ datacenters, regions }: EnergyMapProps) { dcRegion.avgPrice !== null && dcRegion.maxPrice !== null && dcRegion.avgPrice > 0 && - dcRegion.maxPrice > dcRegion.avgPrice * 1.1; + dcRegion.maxPrice > dcRegion.avgPrice * 1.03; return ( ); diff --git a/src/components/map/region-overlay.tsx b/src/components/map/region-overlay.tsx index 49af3c4..77fb719 100644 --- a/src/components/map/region-overlay.tsx +++ b/src/components/map/region-overlay.tsx @@ -1,7 +1,7 @@ 'use client'; import { useMap } from '@vis.gl/react-google-maps'; -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; export interface RegionHeatmapData { code: string; @@ -19,8 +19,15 @@ interface RegionOverlayProps { onRegionClick: (regionCode: string) => void; } -function priceToColor(price: number | null): string { - if (price === null || price <= 0) return 'rgba(100, 100, 100, 0.25)'; +interface PriceColorResult { + fillColor: string; + fillOpacity: number; +} + +function priceToColor(price: number | null): PriceColorResult { + if (price === null || price <= 0) { + return { fillColor: 'rgb(100, 100, 100)', fillOpacity: 0.25 }; + } // Scale: 0-20 cool blue, 20-50 yellow/orange, 50+ red/magenta const clamped = Math.min(price, 100); @@ -29,18 +36,32 @@ function priceToColor(price: number | null): string { if (ratio < 0.2) { // Blue to cyan const t = ratio / 0.2; - return `rgba(${Math.round(30 + t * 30)}, ${Math.round(80 + t * 140)}, ${Math.round(220 - t * 20)}, 0.30)`; + return { + fillColor: `rgb(${Math.round(30 + t * 30)}, ${Math.round(80 + t * 140)}, ${Math.round(220 - t * 20)})`, + fillOpacity: 0.25 + ratio * 0.25, + }; } else if (ratio < 0.5) { // Cyan to yellow/orange const t = (ratio - 0.2) / 0.3; - return `rgba(${Math.round(60 + t * 195)}, ${Math.round(220 - t * 60)}, ${Math.round(200 - t * 170)}, 0.35)`; + return { + fillColor: `rgb(${Math.round(60 + t * 195)}, ${Math.round(220 - t * 60)}, ${Math.round(200 - t * 170)})`, + fillOpacity: 0.3 + (ratio - 0.2) * 0.33, + }; } else { // Orange to red/magenta const t = (ratio - 0.5) / 0.5; - return `rgba(${Math.round(255 - t * 35)}, ${Math.round(160 - t * 120)}, ${Math.round(30 + t * 80)}, 0.40)`; + return { + fillColor: `rgb(${Math.round(255 - t * 35)}, ${Math.round(160 - t * 120)}, ${Math.round(30 + t * 80)})`, + fillOpacity: 0.35 + (ratio - 0.5) * 0.1, + }; } } +/** Return the base fill opacity for a given price (used by breathing animation). */ +function priceToBaseOpacity(price: number | null): number { + return priceToColor(price).fillOpacity; +} + function priceToBorderColor(price: number | null): string { if (price === null || price <= 0) return 'rgba(150, 150, 150, 0.4)'; @@ -56,6 +77,34 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) { const map = useMap(); const dataLayerRef = useRef(null); const listenersRef = useRef([]); + const priceMapRef = useRef>(new Map()); + const hoveredFeatureRef = useRef(null); + const breathingTimerRef = useRef | null>(null); + + /** Apply breathing opacity to all non-hovered features. */ + const applyBreathingFrame = useCallback((dataLayer: google.maps.Data, timestamp: number) => { + dataLayer.forEach(feature => { + // Skip the currently hovered feature — it has its own highlight style + if (feature === hoveredFeatureRef.current) return; + + const rawCode = feature.getProperty('code'); + const code = typeof rawCode === 'string' ? rawCode : ''; + const regionData = priceMapRef.current.get(code); + const price = regionData?.avgPrice ?? null; + + const baseOpacity = priceToBaseOpacity(price); + + // Higher-priced regions breathe faster (higher frequency) and with more amplitude + const priceRatio = price !== null && price > 0 ? Math.min(price, 100) / 100 : 0; + const frequency = 0.8 + priceRatio * 1.2; // 0.8 Hz (cheap) to 2.0 Hz (expensive) + const amplitude = 0.05 + priceRatio * 0.1; // +/- 0.05 (cheap) to +/- 0.15 (expensive) + + const oscillation = Math.sin((timestamp / 1000) * frequency * 2 * Math.PI) * amplitude; + const newOpacity = Math.max(0.1, Math.min(0.5, baseOpacity + oscillation)); + + dataLayer.overrideStyle(feature, { fillOpacity: newOpacity }); + }); + }, []); useEffect(() => { if (!map) return; @@ -69,6 +118,10 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) { listener.remove(); } listenersRef.current = []; + if (breathingTimerRef.current !== null) { + clearInterval(breathingTimerRef.current); + breathingTimerRef.current = null; + } const dataLayer = new google.maps.Data({ map }); dataLayerRef.current = dataLayer; @@ -90,6 +143,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) { } } } + priceMapRef.current = priceMap; // Style features by price dataLayer.setStyle(feature => { @@ -97,10 +151,11 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) { const code = typeof rawCode === 'string' ? rawCode : ''; const regionData = priceMap.get(code); const price = regionData?.avgPrice ?? null; + const { fillColor, fillOpacity } = priceToColor(price); return { - fillColor: priceToColor(price), - fillOpacity: 1, + fillColor, + fillOpacity, strokeColor: priceToBorderColor(price), strokeWeight: 1.5, strokeOpacity: 0.8, @@ -111,10 +166,11 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) { // Hover highlight const overListener = dataLayer.addListener('mouseover', (event: google.maps.Data.MouseEvent) => { if (event.feature) { + hoveredFeatureRef.current = event.feature; dataLayer.overrideStyle(event.feature, { strokeWeight: 3, strokeOpacity: 1, - fillOpacity: 1, + fillOpacity: 0.55, zIndex: 2, }); } @@ -123,6 +179,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) { const outListener = dataLayer.addListener('mouseout', (event: google.maps.Data.MouseEvent) => { if (event.feature) { + hoveredFeatureRef.current = null; dataLayer.revertStyle(event.feature); } }); @@ -137,7 +194,20 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) { }); listenersRef.current.push(clickListener); + // Breathing animation: ~20 FPS interval driving rAF-scheduled style updates + breathingTimerRef.current = setInterval(() => { + requestAnimationFrame(timestamp => { + if (dataLayerRef.current) { + applyBreathingFrame(dataLayerRef.current, timestamp); + } + }); + }, 50); + return () => { + if (breathingTimerRef.current !== null) { + clearInterval(breathingTimerRef.current); + breathingTimerRef.current = null; + } dataLayer.setMap(null); for (const listener of listenersRef.current) { listener.remove(); @@ -145,7 +215,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) { listenersRef.current = []; dataLayerRef.current = null; }; - }, [map, regions, onRegionClick]); + }, [map, regions, onRegionClick, applyBreathingFrame]); return null; }