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:
parent
224a9046fc
commit
3edb69848d
@ -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 (
|
||||
<DatacenterMarker key={dc.id} datacenter={dc} onClick={handleDatacenterClick} isPulsing={isPulsing} />
|
||||
);
|
||||
|
||||
@ -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<google.maps.Data | null>(null);
|
||||
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(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user