feat: keyboard shortcuts, click-to-select, pulse/glow, smooth orbit resume (#4)
* feat: keyboard shortcuts, click-to-select, pulse/glow, smooth orbit resume * feat: add camera controls and enhance keyboard shortcuts help; improve flight card accessibility * feat: enhance flight layers and keyboard shortcuts; improve airline data structure
This commit is contained in:
175
src/components/ui/camera-controls.tsx
Normal file
175
src/components/ui/camera-controls.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useEffect } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
Plus,
|
||||
Minus,
|
||||
ChevronsUp,
|
||||
ChevronsDown,
|
||||
RotateCw,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
|
||||
type CameraActionType = "zoom" | "pitch" | "bearing";
|
||||
|
||||
function dispatchCameraStart(type: CameraActionType, direction: number) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("aeris:camera-start", { detail: { type, direction } }),
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchCameraStop(type: CameraActionType) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("aeris:camera-stop", { detail: { type } }),
|
||||
);
|
||||
}
|
||||
|
||||
function useCameraAction(type: CameraActionType, direction: number) {
|
||||
const activeRef = useRef(false);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (activeRef.current) return;
|
||||
activeRef.current = true;
|
||||
dispatchCameraStart(type, direction);
|
||||
}, [type, direction]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!activeRef.current) return;
|
||||
activeRef.current = false;
|
||||
dispatchCameraStop(type);
|
||||
}, [type]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (activeRef.current) dispatchCameraStop(type);
|
||||
},
|
||||
[type],
|
||||
);
|
||||
|
||||
return { onPointerDown: start, onPointerUp: stop, onPointerLeave: stop };
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
type,
|
||||
direction,
|
||||
label,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
type: CameraActionType;
|
||||
direction: number;
|
||||
label: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const handlers = useCameraAction(type, direction);
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center select-none"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.45)" }}
|
||||
whileHover={{ scale: 1.12 }}
|
||||
whileTap={{ scale: 0.88 }}
|
||||
aria-label={label}
|
||||
title={title}
|
||||
onPointerDown={handlers.onPointerDown}
|
||||
onPointerUp={handlers.onPointerUp}
|
||||
onPointerLeave={handlers.onPointerLeave}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto h-px w-4"
|
||||
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.06)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CameraControls() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
delay: 0.55,
|
||||
}}
|
||||
className="flex flex-col items-center rounded-xl border backdrop-blur-2xl"
|
||||
style={{
|
||||
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
|
||||
}}
|
||||
role="toolbar"
|
||||
aria-label="Camera controls"
|
||||
>
|
||||
<ControlButton type="zoom" direction={1} label="Zoom in" title="Zoom in">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</ControlButton>
|
||||
<Divider />
|
||||
<ControlButton
|
||||
type="zoom"
|
||||
direction={-1}
|
||||
label="Zoom out"
|
||||
title="Zoom out"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</ControlButton>
|
||||
|
||||
<div
|
||||
className="mx-auto my-0.5 h-px w-6"
|
||||
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.10)" }}
|
||||
/>
|
||||
|
||||
<ControlButton
|
||||
type="pitch"
|
||||
direction={-1}
|
||||
label="Tilt up"
|
||||
title="Tilt up (flatter view)"
|
||||
>
|
||||
<ChevronsUp className="h-3.5 w-3.5" />
|
||||
</ControlButton>
|
||||
<Divider />
|
||||
<ControlButton
|
||||
type="pitch"
|
||||
direction={1}
|
||||
label="Tilt down"
|
||||
title="Tilt down (more 3D)"
|
||||
>
|
||||
<ChevronsDown className="h-3.5 w-3.5" />
|
||||
</ControlButton>
|
||||
|
||||
<div
|
||||
className="mx-auto my-0.5 h-px w-6"
|
||||
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.10)" }}
|
||||
/>
|
||||
|
||||
<ControlButton
|
||||
type="bearing"
|
||||
direction={1}
|
||||
label="Rotate clockwise"
|
||||
title="Rotate clockwise"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</ControlButton>
|
||||
<Divider />
|
||||
<ControlButton
|
||||
type="bearing"
|
||||
direction={-1}
|
||||
label="Rotate counter-clockwise"
|
||||
title="Rotate counter-clockwise"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</ControlButton>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user