From c6a59c48a58a4ca82a73a9292219314624a4d7eb Mon Sep 17 00:00:00 2001 From: tsuki Date: Tue, 26 Aug 2025 14:44:00 +0800 Subject: [PATCH] add toolbar --- app/glsl/radar/frag.glsl | 3 +- app/map-context.tsx | 14 +- app/page.tsx | 6 +- app/toolbar.tsx | 106 ++++++++ components/glass-button.tsx | 29 +++ components/liquid-glass-button.tsx | 382 +++++++++++++++++++++++++++++ components/map-component.tsx | 9 +- 7 files changed, 544 insertions(+), 5 deletions(-) create mode 100644 app/toolbar.tsx create mode 100644 components/glass-button.tsx create mode 100644 components/liquid-glass-button.tsx diff --git a/app/glsl/radar/frag.glsl b/app/glsl/radar/frag.glsl index 994d79e..f1e8969 100644 --- a/app/glsl/radar/frag.glsl +++ b/app/glsl/radar/frag.glsl @@ -3,6 +3,7 @@ precision highp float; uniform sampler2D u_tex; uniform sampler2D u_lut; +uniform float u_opacity; in vec2 lonlat; out vec4 fragColor; @@ -42,5 +43,5 @@ void main() { if (alpha <= 0.001) discard; vec4 lutColor = texture(u_lut, vec2(value, 0.5)); - fragColor = vec4(lutColor.rgb, alpha); + fragColor = vec4(lutColor.rgb, alpha * u_opacity); } \ No newline at end of file diff --git a/app/map-context.tsx b/app/map-context.tsx index 05d4d4a..4705c21 100644 --- a/app/map-context.tsx +++ b/app/map-context.tsx @@ -20,6 +20,8 @@ interface MapContextType { setTime: (date: Date) => void setTimelineTime: (date: Date) => void isMapReady: boolean + setOpacity: (opacity: number) => void + opacity: React.RefObject } @@ -43,6 +45,7 @@ export function MapProvider({ children }: MapProviderProps) { const [mapState, setMapState] = useState({ zoomLevel: 11 }); + const mapOpacityRef = useRef(100) const layersRef = useRef([]) const [currentDatetime, setCurrentDatetime] = useState(null) @@ -133,6 +136,13 @@ export function MapProvider({ children }: MapProviderProps) { } } + const setOpacity = (opacity: number) => { + if (mapRef.current) { + mapOpacityRef.current = opacity + mapRef.current.triggerRepaint() + } + } + const value: MapContextType = { timelineDatetime, setTimelineTime: setTimelineDatetime, @@ -148,7 +158,9 @@ export function MapProvider({ children }: MapProviderProps) { zoomTo, reset, clearMap, - isMapReady + isMapReady, + setOpacity, + opacity: mapOpacityRef } return ( diff --git a/app/page.tsx b/app/page.tsx index d3a3025..8f3ce10 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,7 @@ import { Timeline } from '@/app/tl'; import { WSProvider } from './ws-context' import StatusBar from './status-bar' import { getSiteConfigs } from '@/lib/fetchers'; +import Toolbar from './toolbar'; type Props = { params: Promise<{ id: string }> @@ -38,10 +39,13 @@ export default function Page() {
- {/* Timeline with responsive layout - single row on desktop, double row on mobile */}
+ +
+ +
diff --git a/app/toolbar.tsx b/app/toolbar.tsx new file mode 100644 index 0000000..a9c93ea --- /dev/null +++ b/app/toolbar.tsx @@ -0,0 +1,106 @@ +'use client' + +import { GlassButton } from "@/components/glass-button" +import { useRouter } from "next/navigation" +import { LightbulbIcon, LightbulbOffIcon, User2Icon } from "lucide-react" +import { useState } from "react"; +import { useMap } from "./map-context"; + +export default function Toolbar() { + const router = useRouter(); + + const { setOpacity, opacity } = useMap(); + + return ( +
+ { + router.push('/me'); + } + }> + + + + + +
+ ) +} + +function Opacity({ initOpacity, setOpacity }: { initOpacity: number, setOpacity: (opacity: number) => void }) { + + const [opacity, setIOpacity] = useState(initOpacity); + + const handleOpacityChange = (e: React.ChangeEvent) => { + const newOpacity = parseFloat(e.target.value); + setOpacity(newOpacity); + setIOpacity(newOpacity); + }; + + return ( + +
+ { + setOpacity(100); + setIOpacity(100); + }} /> +
+ + + +
+ { + setOpacity(0); + setIOpacity(0); + }} /> +
+ + + + ) +} \ No newline at end of file diff --git a/components/glass-button.tsx b/components/glass-button.tsx new file mode 100644 index 0000000..bb56413 --- /dev/null +++ b/components/glass-button.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +export interface GlassButtonProps { + children: React.ReactNode; + onClick?: () => void; + className?: string; + disabled?: boolean; + type?: "button" | "submit" | "reset"; +} + +export const GlassButton: React.FC = ({ + children, + onClick, + className = "", + disabled = false, + type = "button", +}) => { + return ( + + ); +}; \ No newline at end of file diff --git a/components/liquid-glass-button.tsx b/components/liquid-glass-button.tsx new file mode 100644 index 0000000..009cd1d --- /dev/null +++ b/components/liquid-glass-button.tsx @@ -0,0 +1,382 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-primary-foreground hover:bg-destructive/90", + cool: "dark:inset-shadow-2xs dark:inset-shadow-white/10 bg-linear-to-t border border-b-2 border-zinc-950/40 from-primary to-primary/85 shadow-md shadow-primary/20 ring-1 ring-inset ring-white/25 transition-[filter] duration-200 hover:brightness-110 active:brightness-90 dark:border-x-0 text-primary-foreground dark:text-primary-foreground dark:border-t-0 dark:border-primary/50 dark:ring-white/5", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants, liquidbuttonVariants, LiquidButton } + +const liquidbuttonVariants = cva( + "inline-flex items-center transition-colors justify-center cursor-pointer gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-transparent hover:scale-105 duration-300 transition text-primary", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 text-xs gap-1.5 px-4 has-[>svg]:px-4", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + xl: "h-12 rounded-md px-8 has-[>svg]:px-6", + xxl: "h-14 rounded-md px-10 has-[>svg]:px-8", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "xxl", + }, + } +) + +function LiquidButton({ + className, + variant, + size, + asChild = false, + children, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + <> + +
+
+ +
+ {children} +
+ + + + ) +} + + +function GlassFilter() { + return ( + + + + {/* Generate turbulent noise for distortion */} + + + {/* Blur the turbulence pattern slightly */} + + + {/* Displace the source graphic with the noise */} + + + {/* Apply overall blur on the final result */} + + + {/* Output the result */} + + + + + ); +} + +type ColorVariant = + | "default" + | "primary" + | "success" + | "error" + | "gold" + | "bronze"; + +interface MetalButtonProps + extends React.ButtonHTMLAttributes { + variant?: ColorVariant; +} + +const colorVariants: Record< + ColorVariant, + { + outer: string; + inner: string; + button: string; + textColor: string; + textShadow: string; + } +> = { + default: { + outer: "bg-gradient-to-b from-[#000] to-[#A0A0A0]", + inner: "bg-gradient-to-b from-[#FAFAFA] via-[#3E3E3E] to-[#E5E5E5]", + button: "bg-gradient-to-b from-[#B9B9B9] to-[#969696]", + textColor: "text-white", + textShadow: "[text-shadow:_0_-1px_0_rgb(80_80_80_/_100%)]", + }, + primary: { + outer: "bg-gradient-to-b from-[#000] to-[#A0A0A0]", + inner: "bg-gradient-to-b from-primary via-secondary to-muted", + button: "bg-gradient-to-b from-primary to-primary/40", + textColor: "text-white", + textShadow: "[text-shadow:_0_-1px_0_rgb(30_58_138_/_100%)]", + }, + success: { + outer: "bg-gradient-to-b from-[#005A43] to-[#7CCB9B]", + inner: "bg-gradient-to-b from-[#E5F8F0] via-[#00352F] to-[#D1F0E6]", + button: "bg-gradient-to-b from-[#9ADBC8] to-[#3E8F7C]", + textColor: "text-[#FFF7F0]", + textShadow: "[text-shadow:_0_-1px_0_rgb(6_78_59_/_100%)]", + }, + error: { + outer: "bg-gradient-to-b from-[#5A0000] to-[#FFAEB0]", + inner: "bg-gradient-to-b from-[#FFDEDE] via-[#680002] to-[#FFE9E9]", + button: "bg-gradient-to-b from-[#F08D8F] to-[#A45253]", + textColor: "text-[#FFF7F0]", + textShadow: "[text-shadow:_0_-1px_0_rgb(146_64_14_/_100%)]", + }, + gold: { + outer: "bg-gradient-to-b from-[#917100] to-[#EAD98F]", + inner: "bg-gradient-to-b from-[#FFFDDD] via-[#856807] to-[#FFF1B3]", + button: "bg-gradient-to-b from-[#FFEBA1] to-[#9B873F]", + textColor: "text-[#FFFDE5]", + textShadow: "[text-shadow:_0_-1px_0_rgb(178_140_2_/_100%)]", + }, + bronze: { + outer: "bg-gradient-to-b from-[#864813] to-[#E9B486]", + inner: "bg-gradient-to-b from-[#EDC5A1] via-[#5F2D01] to-[#FFDEC1]", + button: "bg-gradient-to-b from-[#FFE3C9] to-[#A36F3D]", + textColor: "text-[#FFF7F0]", + textShadow: "[text-shadow:_0_-1px_0_rgb(124_45_18_/_100%)]", + }, +}; + +const metalButtonVariants = ( + variant: ColorVariant = "default", + isPressed: boolean, + isHovered: boolean, + isTouchDevice: boolean, +) => { + const colors = colorVariants[variant]; + const transitionStyle = "all 250ms cubic-bezier(0.1, 0.4, 0.2, 1)"; + + return { + wrapper: cn( + "relative inline-flex transform-gpu rounded-md p-[1.25px] will-change-transform", + colors.outer, + ), + wrapperStyle: { + transform: isPressed + ? "translateY(2.5px) scale(0.99)" + : "translateY(0) scale(1)", + boxShadow: isPressed + ? "0 1px 2px rgba(0, 0, 0, 0.15)" + : isHovered && !isTouchDevice + ? "0 4px 12px rgba(0, 0, 0, 0.12)" + : "0 3px 8px rgba(0, 0, 0, 0.08)", + transition: transitionStyle, + transformOrigin: "center center", + }, + inner: cn( + "absolute inset-[1px] transform-gpu rounded-lg will-change-transform", + colors.inner, + ), + innerStyle: { + transition: transitionStyle, + transformOrigin: "center center", + filter: + isHovered && !isPressed && !isTouchDevice ? "brightness(1.05)" : "none", + }, + button: cn( + "relative z-10 m-[1px] rounded-md inline-flex h-11 transform-gpu cursor-pointer items-center justify-center overflow-hidden rounded-md px-6 py-2 text-sm leading-none font-semibold will-change-transform outline-none", + colors.button, + colors.textColor, + colors.textShadow, + ), + buttonStyle: { + transform: isPressed ? "scale(0.97)" : "scale(1)", + transition: transitionStyle, + transformOrigin: "center center", + filter: + isHovered && !isPressed && !isTouchDevice ? "brightness(1.02)" : "none", + }, + }; +}; + +const ShineEffect = ({ isPressed }: { isPressed: boolean }) => { + return ( +
+
+
+ ); +}; + +export const MetalButton = React.forwardRef< + HTMLButtonElement, + MetalButtonProps +>(({ children, className, variant = "default", ...props }, ref) => { + const [isPressed, setIsPressed] = React.useState(false); + const [isHovered, setIsHovered] = React.useState(false); + const [isTouchDevice, setIsTouchDevice] = React.useState(false); + + React.useEffect(() => { + setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0); + }, []); + + const buttonText = children || "Button"; + const variants = metalButtonVariants( + variant, + isPressed, + isHovered, + isTouchDevice, + ); + + const handleInternalMouseDown = () => { + setIsPressed(true); + }; + const handleInternalMouseUp = () => { + setIsPressed(false); + }; + const handleInternalMouseLeave = () => { + setIsPressed(false); + setIsHovered(false); + }; + const handleInternalMouseEnter = () => { + if (!isTouchDevice) { + setIsHovered(true); + } + }; + const handleInternalTouchStart = () => { + setIsPressed(true); + }; + const handleInternalTouchEnd = () => { + setIsPressed(false); + }; + const handleInternalTouchCancel = () => { + setIsPressed(false); + }; + + return ( +
+
+ +
+ ); +}); + +MetalButton.displayName = "MetalButton"; \ No newline at end of file diff --git a/components/map-component.tsx b/components/map-component.tsx index cd11db3..38cce6c 100644 --- a/components/map-component.tsx +++ b/components/map-component.tsx @@ -51,7 +51,7 @@ export function MapComponent({ } }); const mapContainer = useRef(null) - const { setMap, mapRef, currentDatetime, isMapReady } = useMap() + const { setMap, mapRef, currentDatetime, isMapReady, opacity } = useMap() const { location } = useMapLocation() const texRef = useRef(null) const lutTexRef = useRef(null) @@ -254,7 +254,8 @@ export function MapComponent({ 'u_projection_clipping_plane': gl.getUniformLocation(program, 'u_projection_clipping_plane'), 'u_projection_transition': gl.getUniformLocation(program, 'u_projection_transition'), 'u_tex': gl.getUniformLocation(program, 'u_tex'), - 'u_lut': gl.getUniformLocation(program, 'u_lut') + 'u_lut': gl.getUniformLocation(program, 'u_lut'), + 'u_opacity': gl.getUniformLocation(program, 'u_opacity') }; // 创建并绑定顶点缓冲区 @@ -474,6 +475,10 @@ export function MapComponent({ gl.uniform1i(locations['u_lut'], 1); } + if (locations['u_opacity']) { + gl.uniform1f(locations['u_opacity'], opacity.current / 100); + } + gl.bindVertexArray(this.vao); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);