fix: region overlay visibility, ambient glow breathing, pulsing threshold

- Fix fillColor/fillOpacity conflict: return separate RGB string and
  opacity from priceToColor() instead of rgba with embedded alpha
- Implement ambient region glow via setInterval + overrideStyle with
  sine-wave opacity oscillation (faster/brighter for higher prices)
- Lower pulsing marker threshold from 10% to 3% for demand-varied prices
This commit is contained in:
Joey Eamigh 2026-02-11 13:36:21 -05:00
parent 224a9046fc
commit 3edb69848d
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
2 changed files with 81 additions and 11 deletions

View File

@ -65,7 +65,7 @@ export function EnergyMap({ datacenters, regions }: EnergyMapProps) {
dcRegion.avgPrice !== null && dcRegion.avgPrice !== null &&
dcRegion.maxPrice !== null && dcRegion.maxPrice !== null &&
dcRegion.avgPrice > 0 && dcRegion.avgPrice > 0 &&
dcRegion.maxPrice > dcRegion.avgPrice * 1.1; dcRegion.maxPrice > dcRegion.avgPrice * 1.03;
return ( return (
<DatacenterMarker key={dc.id} datacenter={dc} onClick={handleDatacenterClick} isPulsing={isPulsing} /> <DatacenterMarker key={dc.id} datacenter={dc} onClick={handleDatacenterClick} isPulsing={isPulsing} />
); );

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useMap } from '@vis.gl/react-google-maps'; import { useMap } from '@vis.gl/react-google-maps';
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
export interface RegionHeatmapData { export interface RegionHeatmapData {
code: string; code: string;
@ -19,8 +19,15 @@ interface RegionOverlayProps {
onRegionClick: (regionCode: string) => void; onRegionClick: (regionCode: string) => void;
} }
function priceToColor(price: number | null): string { interface PriceColorResult {
if (price === null || price <= 0) return 'rgba(100, 100, 100, 0.25)'; 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 // Scale: 0-20 cool blue, 20-50 yellow/orange, 50+ red/magenta
const clamped = Math.min(price, 100); const clamped = Math.min(price, 100);
@ -29,18 +36,32 @@ function priceToColor(price: number | null): string {
if (ratio < 0.2) { if (ratio < 0.2) {
// Blue to cyan // Blue to cyan
const t = ratio / 0.2; 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) { } else if (ratio < 0.5) {
// Cyan to yellow/orange // Cyan to yellow/orange
const t = (ratio - 0.2) / 0.3; 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 { } else {
// Orange to red/magenta // Orange to red/magenta
const t = (ratio - 0.5) / 0.5; 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 { function priceToBorderColor(price: number | null): string {
if (price === null || price <= 0) return 'rgba(150, 150, 150, 0.4)'; 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 map = useMap();
const dataLayerRef = useRef<google.maps.Data | null>(null); const dataLayerRef = useRef<google.maps.Data | null>(null);
const listenersRef = useRef<google.maps.MapsEventListener[]>([]); const listenersRef = useRef<google.maps.MapsEventListener[]>([]);
const priceMapRef = useRef<Map<string, RegionHeatmapData>>(new Map());
const hoveredFeatureRef = useRef<google.maps.Data.Feature | null>(null);
const breathingTimerRef = useRef<ReturnType<typeof setInterval> | 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(() => { useEffect(() => {
if (!map) return; if (!map) return;
@ -69,6 +118,10 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
listener.remove(); listener.remove();
} }
listenersRef.current = []; listenersRef.current = [];
if (breathingTimerRef.current !== null) {
clearInterval(breathingTimerRef.current);
breathingTimerRef.current = null;
}
const dataLayer = new google.maps.Data({ map }); const dataLayer = new google.maps.Data({ map });
dataLayerRef.current = dataLayer; dataLayerRef.current = dataLayer;
@ -90,6 +143,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
} }
} }
} }
priceMapRef.current = priceMap;
// Style features by price // Style features by price
dataLayer.setStyle(feature => { dataLayer.setStyle(feature => {
@ -97,10 +151,11 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
const code = typeof rawCode === 'string' ? rawCode : ''; const code = typeof rawCode === 'string' ? rawCode : '';
const regionData = priceMap.get(code); const regionData = priceMap.get(code);
const price = regionData?.avgPrice ?? null; const price = regionData?.avgPrice ?? null;
const { fillColor, fillOpacity } = priceToColor(price);
return { return {
fillColor: priceToColor(price), fillColor,
fillOpacity: 1, fillOpacity,
strokeColor: priceToBorderColor(price), strokeColor: priceToBorderColor(price),
strokeWeight: 1.5, strokeWeight: 1.5,
strokeOpacity: 0.8, strokeOpacity: 0.8,
@ -111,10 +166,11 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
// Hover highlight // Hover highlight
const overListener = dataLayer.addListener('mouseover', (event: google.maps.Data.MouseEvent) => { const overListener = dataLayer.addListener('mouseover', (event: google.maps.Data.MouseEvent) => {
if (event.feature) { if (event.feature) {
hoveredFeatureRef.current = event.feature;
dataLayer.overrideStyle(event.feature, { dataLayer.overrideStyle(event.feature, {
strokeWeight: 3, strokeWeight: 3,
strokeOpacity: 1, strokeOpacity: 1,
fillOpacity: 1, fillOpacity: 0.55,
zIndex: 2, zIndex: 2,
}); });
} }
@ -123,6 +179,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
const outListener = dataLayer.addListener('mouseout', (event: google.maps.Data.MouseEvent) => { const outListener = dataLayer.addListener('mouseout', (event: google.maps.Data.MouseEvent) => {
if (event.feature) { if (event.feature) {
hoveredFeatureRef.current = null;
dataLayer.revertStyle(event.feature); dataLayer.revertStyle(event.feature);
} }
}); });
@ -137,7 +194,20 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
}); });
listenersRef.current.push(clickListener); 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 () => { return () => {
if (breathingTimerRef.current !== null) {
clearInterval(breathingTimerRef.current);
breathingTimerRef.current = null;
}
dataLayer.setMap(null); dataLayer.setMap(null);
for (const listener of listenersRef.current) { for (const listener of listenersRef.current) {
listener.remove(); listener.remove();
@ -145,7 +215,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
listenersRef.current = []; listenersRef.current = [];
dataLayerRef.current = null; dataLayerRef.current = null;
}; };
}, [map, regions, onRegionClick]); }, [map, regions, onRegionClick, applyBreathingFrame]);
return null; return null;
} }