feat(ui): implement dynamic view transitions and ambient gradients for themes
This commit is contained in:
1 parent
c7ad4a2da9
commit
31f087b0ba
18 files changed
+1577
-199
No files matched your search
@@ -52,7 +52,7 @@ export function DataTable<T>({
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50">
|
||||
<tr>
|
||||
{columns.map((col, i) => (
|
||||
<th key={i} className={`px-4 py-3.5 font-medium whitespace-nowrap tracking-wide ${col.className || ""}`}>
|
||||
<th key={i} className={`px-4 py-3.5 font-medium tracking-wide ${col.className || ""}`}>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
@@ -91,7 +91,7 @@ export function DataTable<T>({
|
||||
className={`hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} className={`px-4 py-3 whitespace-nowrap text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
<td key={j} className={`px-4 py-3 text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
</td>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { fmtBytes } from '@/lib/utils';
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
// Dynamically import Globe to avoid SSR issues
|
||||
const Globe = dynamic(() => import('react-globe.gl'), { ssr: false });
|
||||
@@ -40,7 +41,46 @@ interface GlobeMapProps {
|
||||
export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
const globeRef = useRef<any>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 500 });
|
||||
const [countries, setCountries] = useState<any>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
// Generate a beautiful, ultra-fresh gradient blue texture for the Light Mode ocean
|
||||
const lightGlobeTexture = useMemo(() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1024;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 512);
|
||||
gradient.addColorStop(0, '#bae6fd'); // sky-200 (light bright blue at the top)
|
||||
gradient.addColorStop(0.5, '#38bdf8'); // sky-400 (vibrant blue equator)
|
||||
gradient.addColorStop(1, '#0284c7'); // sky-600 (deeper rich blue at the bottom)
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 1024, 512);
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}, []);
|
||||
|
||||
// Generate a sleek, deep gradient dark texture for the Dark Mode ocean
|
||||
const darkGlobeTexture = useMemo(() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1024;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 512);
|
||||
gradient.addColorStop(0, '#0f172a'); // slate-900
|
||||
gradient.addColorStop(0.5, '#020617'); // slate-950 (deepest dark blue/black)
|
||||
gradient.addColorStop(1, '#000000'); // black
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 1024, 512);
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle resizing
|
||||
@@ -52,21 +92,47 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
updateDimensions();
|
||||
window.addEventListener('resize', updateDimensions);
|
||||
|
||||
// Fetch high-res vector polygons for both themes' continents
|
||||
fetch('https://unpkg.com/globe.gl/example/datasets/ne_110m_admin_0_countries.geojson')
|
||||
.then(res => res.json())
|
||||
.then(setCountries)
|
||||
.catch(err => console.error('Failed to load countries', err));
|
||||
|
||||
return () => window.removeEventListener('resize', updateDimensions);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-rotate configuration
|
||||
if (globeRef.current) {
|
||||
globeRef.current.controls().autoRotate = true;
|
||||
globeRef.current.controls().autoRotateSpeed = 1.0;
|
||||
|
||||
// Focus on Southeast Asia initially
|
||||
globeRef.current.pointOfView({ lat: ORIGIN_LAT, lng: ORIGIN_LNG, altitude: 1.8 }, 2000);
|
||||
}
|
||||
// Auto-rotate and controls configuration
|
||||
// Since Globe is dynamically imported, we must wait until the ref is actually populated.
|
||||
let checkInterval = setInterval(() => {
|
||||
if (globeRef.current && globeRef.current.controls) {
|
||||
clearInterval(checkInterval);
|
||||
|
||||
const controls = globeRef.current.controls();
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 1.2; // Adjusted auto-rotate speed to 1.5
|
||||
|
||||
// Enable inertia/damping for smooth deceleration after dragging
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.035;
|
||||
|
||||
// Adjust the manual rotation sensitivity
|
||||
controls.rotateSpeed = 0.8;
|
||||
|
||||
// Remove boundaries to allow 360 degree rotation in all directions (as much as OrbitControls allows)
|
||||
controls.minPolarAngle = 0; // The top pole (North)
|
||||
controls.maxPolarAngle = Math.PI; // The bottom pole (South)
|
||||
|
||||
// Focus on Southeast Asia initially
|
||||
globeRef.current.pointOfView({ lat: ORIGIN_LAT, lng: ORIGIN_LNG, altitude: 1.8 }, 2000);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}, []);
|
||||
|
||||
const arcsData = useMemo(() => {
|
||||
@@ -95,7 +161,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
name: arc.name,
|
||||
value: arc.value
|
||||
}));
|
||||
|
||||
|
||||
// Add origin point
|
||||
points.push({
|
||||
lat: ORIGIN_LAT,
|
||||
@@ -105,30 +171,34 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
name: 'Indonesia (Origin)',
|
||||
value: 0
|
||||
});
|
||||
|
||||
|
||||
return points;
|
||||
}, [arcsData]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-[500px] rounded-xl overflow-visible bg-transparent cursor-move flex items-center justify-center"
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-[500px] rounded-xl overflow-visible bg-transparent cursor-move flex items-center justify-center transition-all duration-500"
|
||||
>
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-black/60 p-3 rounded-lg border border-white/10 backdrop-blur-md">
|
||||
<div className="text-sm font-medium text-white flex items-center gap-2">
|
||||
Global Traffic Flows
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">Interactive 3D visualization of network traffic</p>
|
||||
</div>
|
||||
|
||||
<Globe
|
||||
ref={globeRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg"
|
||||
bumpImageUrl="//unpkg.com/three-globe/example/img/earth-topology.png"
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
showGraticules={true}
|
||||
globeImageUrl={isLight ? lightGlobeTexture : darkGlobeTexture}
|
||||
showAtmosphere={true}
|
||||
atmosphereColor={isLight ? "#0ea5e9" : "#1e40af"}
|
||||
atmosphereAltitude={isLight ? 0.15 : 0.25}
|
||||
|
||||
// Polygons Configuration for Unified Vector Designs
|
||||
polygonsData={countries ? countries.features : []}
|
||||
polygonAltitude={0.015}
|
||||
polygonCapColor={() => isLight ? '#4ade80' : '#0f172a'} // Fresh green for light, dark slate for dark
|
||||
polygonSideColor={() => isLight ? '#22c55e' : '#020617'} // Depth colors
|
||||
polygonStrokeColor={() => isLight ? '#bbf7d0' : '#0284c7'} // Crisp bright borders for light, neon blue for dark
|
||||
|
||||
// Arcs configuration
|
||||
arcsData={arcsData}
|
||||
arcStartLat={(d: any) => d.startLat}
|
||||
@@ -141,7 +211,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
arcDashAnimateTime={2000}
|
||||
arcAltitude={0.3}
|
||||
arcStroke={0.7}
|
||||
|
||||
|
||||
// Points configuration
|
||||
pointsData={pointsData}
|
||||
pointLat="lat"
|
||||
@@ -150,7 +220,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
pointAltitude={0.01}
|
||||
pointRadius="size"
|
||||
pointsMerge={true}
|
||||
|
||||
|
||||
// Rings configuration (Ping effect for points)
|
||||
ringsData={pointsData}
|
||||
ringLat="lat"
|
||||
@@ -159,7 +229,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
ringMaxRadius={(d: any) => d.name === 'Indonesia (Origin)' ? 3 : 1.5}
|
||||
ringPropagationSpeed={3}
|
||||
ringRepeatPeriod={1000}
|
||||
|
||||
|
||||
// Labels configuration
|
||||
labelsData={pointsData}
|
||||
labelLat="lat"
|
||||
@@ -167,7 +237,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
labelText="name"
|
||||
labelSize={1.2}
|
||||
labelDotRadius={0.5}
|
||||
labelColor={() => 'rgba(255, 255, 255, 0.8)'}
|
||||
labelColor={() => isLight ? '#0f172a' : 'rgba(255, 255, 255, 0.8)'}
|
||||
labelResolution={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -29,7 +30,7 @@ export function Modal({ isOpen, onClose, title, children, className }: ModalProp
|
||||
|
||||
if (!isMounted || !isOpen) return null;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
@@ -56,6 +57,7 @@ export function Modal({ isOpen, onClose, title, children, className }: ModalProp
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -11,16 +11,11 @@ import {
|
||||
} from "react-simple-maps";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
// The topojson file containing the world shapes
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
// Our custom heatmap color scale
|
||||
// Maps values from 0 to maxValue to a gradient (Dark Blue to Electric Cyan/Purple)
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain([0, 100]) // We will update the domain dynamically based on data
|
||||
.range(["#1e293b", "#38bdf8"]); // Tailwind slate-800 to sky-400
|
||||
|
||||
interface WorldMapProps {
|
||||
data: {
|
||||
countryCode: string; // ISO A2 code
|
||||
@@ -38,14 +33,27 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
const max = Math.max(...data.map(d => d.value));
|
||||
setMaxValue(max);
|
||||
colorScale.domain([0, max]);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain(maxValue > 0 ? [0, maxValue * 0.33, maxValue * 0.66, maxValue] : [0, 1, 2, 3])
|
||||
.range(isLight
|
||||
? ["#bbf7d0", "#fef08a", "#fb923c", "#ef4444"] // Light: Green -> Yellow -> Orange -> Red
|
||||
: ["#1e293b", "#0f172a", "#0284c7", "#38bdf8"] // Dark: Slate -> Sky
|
||||
);
|
||||
|
||||
const defaultLandColor = isLight ? "#86efac" : "#1e293b";
|
||||
const oceanColor = isLight ? "#bfdbfe" : "transparent";
|
||||
const strokeColor = isLight ? "rgba(0,0,0,0.15)" : "rgba(255,255,255,0.1)";
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
@@ -56,7 +64,14 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="text-xs text-muted-foreground">Low</div>
|
||||
<div className="w-24 h-2 rounded-full bg-gradient-to-r from-slate-800 to-sky-400 shadow-sm"></div>
|
||||
<div
|
||||
className="w-24 h-2 rounded-full shadow-sm"
|
||||
style={{
|
||||
background: isLight
|
||||
? 'linear-gradient(to right, #bbf7d0, #fef08a, #fb923c, #ef4444)'
|
||||
: 'linear-gradient(to right, #1e293b, #38bdf8)'
|
||||
}}
|
||||
></div>
|
||||
<div className="text-xs text-muted-foreground">High</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,8 +95,8 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
className="w-full h-full"
|
||||
>
|
||||
<ZoomableGroup zoom={1} maxZoom={4} translateExtent={[[-100, -100], [900, 500]]}>
|
||||
<Sphere stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} id="sphere" fill="transparent" />
|
||||
<Graticule stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
|
||||
<Sphere stroke={strokeColor} strokeWidth={0.5} id="sphere" fill={oceanColor} />
|
||||
<Graticule stroke={strokeColor} strokeWidth={0.5} />
|
||||
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
@@ -99,14 +114,14 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
(sLabel === 'south korea' && geoName === 'korea');
|
||||
});
|
||||
|
||||
const color = d ? colorScale(d.value) : "#1e293b"; // Default color
|
||||
const color = d ? colorScale(d.value) : defaultLandColor;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={color}
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
|
||||
Reference in new issue
Block a user