feat(ui): implement dynamic view transitions and ambient gradients for themes

This commit is contained in:
Rafif-Riqullah-Siregar committed 2026-07-01 17:48:38 +07:00
1 parent c7ad4a2da9
commit 31f087b0ba
18 files changed
+1577 -199

No files matched your search

+2 -2
View File
@@ -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>
))}
+96 -26
View File
@@ -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>
+4 -2
View File
@@ -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
);
}
+27 -12
View File
@@ -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" },