Skip to content

Commit

Permalink
feat: new tooltip
Browse files Browse the repository at this point in the history
  • Loading branch information
hamster1963 committed Dec 7, 2024
1 parent 7d61b66 commit 6712492
Show file tree
Hide file tree
Showing 12 changed files with 187 additions and 64 deletions.
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default tseslint.config(
{ allowConstantExport: true },
],
"react-hooks/exhaustive-deps": "off",
"@typescript-eslint/no-explicit-any": "off",
},
},
);
97 changes: 54 additions & 43 deletions src/components/GlobalMap.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { geoJsonString } from "@/lib/geo-json-string";
import { NezhaServer } from "@/types/nezha-api";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, m } from "framer-motion";
import { geoEquirectangular, geoPath } from "d3-geo";
import { countryCoordinates } from "@/lib/geo-limit";
import MapTooltip from "./MapTooltip";
import useTooltip from "@/hooks/use-tooltip";
import { formatNezhaInfo } from "@/lib/utils";

export default function GlobalMap({
serverList,
now,
}: {
serverList: NezhaServer[];
now: number;
}) {
const { t } = useTranslation();
const countryList: string[] = [];
const serverCounts: { [key: string]: number } = {};

console.log(serverList);

serverList.forEach((server) => {
if (server.country_code) {
const countryCode = server.country_code.toUpperCase();
Expand Down Expand Up @@ -48,6 +49,8 @@ export default function GlobalMap({
width={width}
height={height}
filteredFeatures={filteredFeatures}
nezhaServerList={serverList}
now={now}
/>
</div>
</section>
Expand All @@ -67,21 +70,20 @@ interface InteractiveMapProps {
};
geometry: never;
}[];
nezhaServerList: NezhaServer[];
now: number;
}

function InteractiveMap({
export function InteractiveMap({
countries,
serverCounts,
width,
height,
filteredFeatures,
nezhaServerList,
now,
}: InteractiveMapProps) {
const { t } = useTranslation();
const [tooltipData, setTooltipData] = useState<{
centroid: [number, number];
country: string;
count: number;
} | null>(null);
const { setTooltipData } = useTooltip();

const projection = geoEquirectangular()
.scale(140)
Expand All @@ -91,7 +93,10 @@ function InteractiveMap({
const path = geoPath().projection(projection);

return (
<div className="relative w-full aspect-[2/1]">
<div
className="relative w-full aspect-[2/1]"
onMouseLeave={() => setTooltipData(null)}
>
<svg
width={width}
height={height}
Expand All @@ -105,15 +110,20 @@ function InteractiveMap({
</pattern>
</defs>
<g>
{/* Background rect to handle mouse events in empty areas */}
<rect
x="0"
y="0"
width={width}
height={height}
fill="transparent"
onMouseEnter={() => setTooltipData(null)}
/>
{filteredFeatures.map((feature, index) => {
const isHighlighted = countries.includes(
feature.properties.iso_a2_eh,
);

if (isHighlighted) {
console.log(feature.properties.iso_a2_eh);
}

const serverCount = serverCounts[feature.properties.iso_a2_eh] || 0;

return (
Expand All @@ -126,15 +136,29 @@ function InteractiveMap({
: "fill-neutral-200/50 dark:fill-neutral-800 stroke-neutral-300/40 dark:stroke-neutral-700 stroke-[0.5]"
}
onMouseEnter={() => {
if (isHighlighted && path.centroid(feature)) {
if (!isHighlighted) {
setTooltipData(null);
return;
}
if (path.centroid(feature)) {
const countryCode = feature.properties.iso_a2_eh;
const countryServers = nezhaServerList
.filter(
(server: NezhaServer) =>
server.country_code?.toUpperCase() === countryCode,
)
.map((server: NezhaServer) => ({
name: server.name,
status: formatNezhaInfo(now, server).online,
}));
setTooltipData({
centroid: path.centroid(feature),
country: feature.properties.name,
count: serverCount,
servers: countryServers,
});
}
}}
onMouseLeave={() => setTooltipData(null)}
/>
);
})}
Expand All @@ -161,13 +185,23 @@ function InteractiveMap({
<g
key={countryCode}
onMouseEnter={() => {
const countryServers = nezhaServerList
.filter(
(server: NezhaServer) =>
server.country_code?.toUpperCase() ===
countryCode.toUpperCase(),
)
.map((server: NezhaServer) => ({
name: server.name,
status: formatNezhaInfo(now, server).online,
}));
setTooltipData({
centroid: [x, y],
country: coords.name,
count: serverCount,
servers: countryServers,
});
}}
onMouseLeave={() => setTooltipData(null)}
className="cursor-pointer"
>
<circle
Expand All @@ -181,30 +215,7 @@ function InteractiveMap({
})}
</g>
</svg>
<AnimatePresence mode="wait">
{tooltipData && (
<m.div
initial={{ opacity: 0, filter: "blur(10px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
className="absolute hidden lg:block pointer-events-none bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(-50%, -50%)",
}}
>
<p className="font-medium">
{tooltipData.country === "China"
? "Mainland China"
: tooltipData.country}
</p>
<p className="text-neutral-600 dark:text-neutral-400">
{tooltipData.count} {t("map.Servers")}
</p>
</m.div>
)}
</AnimatePresence>
<MapTooltip />
</div>
);
}
62 changes: 62 additions & 0 deletions src/components/MapTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import useTooltip from "@/hooks/use-tooltip";
import { AnimatePresence, m } from "framer-motion";
import { memo } from "react";
import { useTranslation } from "react-i18next";

const MapTooltip = memo(function MapTooltip() {
const { t } = useTranslation();
const { tooltipData } = useTooltip();

if (!tooltipData) return null;

return (
<AnimatePresence mode="wait">
<m.div
initial={{ opacity: 0, filter: "blur(10px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(10px)" }}
className="absolute hidden lg:block bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700 z-50"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(20%, -50%)",
}}
onMouseEnter={(e) => {
e.stopPropagation();
}}
>
<div>
<p className="font-medium">
{tooltipData.country === "China"
? "Mainland China"
: tooltipData.country}
</p>
<p className="text-neutral-600 dark:text-neutral-400 mb-1">
{tooltipData.count} {t("map.Servers")}
</p>
</div>
<div
className="border-t dark:border-neutral-700 pt-1"
style={{
maxHeight: "200px",
overflowY: "auto",
}}
>
{tooltipData.servers.map((server, index: number) => (
<div key={index} className="flex items-center gap-1.5 py-0.5">
<span
className={`w-1.5 h-1.5 shrink-0 rounded-full ${
server.status ? "bg-green-500" : "bg-red-500"
}`}
></span>
<span className="text-xs">{server.name}</span>
</div>
))}
</div>
</m.div>
</AnimatePresence>
);
});

export default MapTooltip;
2 changes: 0 additions & 2 deletions src/context/status-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import { ReactNode, useState } from "react";
import { Status, StatusContext } from "./status-context";

Expand Down
20 changes: 20 additions & 0 deletions src/context/tooltip-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createContext } from "react";

export interface TooltipData {
centroid: [number, number];
country: string;
count: number;
servers: Array<{
name: string;
status: boolean;
}>;
}

interface TooltipContextType {
tooltipData: TooltipData | null;
setTooltipData: (data: TooltipData | null) => void;
}

export const TooltipContext = createContext<TooltipContextType | undefined>(
undefined,
);
12 changes: 12 additions & 0 deletions src/context/tooltip-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ReactNode, useState } from "react";
import { TooltipContext, TooltipData } from "./tooltip-context";

export function TooltipProvider({ children }: { children: ReactNode }) {
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null);

return (
<TooltipContext.Provider value={{ tooltipData, setTooltipData }}>
{children}
</TooltipContext.Provider>
);
}
12 changes: 12 additions & 0 deletions src/hooks/use-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useContext } from "react";
import { TooltipContext } from "@/context/tooltip-context";

export const useTooltip = () => {
const context = useContext(TooltipContext);
if (context === undefined) {
throw new Error("useTooltip must be used within a TooltipProvider");
}
return context;
};

export default useTooltip;
29 changes: 16 additions & 13 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { MotionProvider } from "./components/motion/motion-provider";
import { WebSocketProvider } from "./context/websocket-provider";
import { StatusProvider } from "./context/status-provider";
import { FilterProvider } from "./context/network-filter-context";
import { TooltipProvider } from "./context/tooltip-provider";

const queryClient = new QueryClient();

Expand All @@ -22,19 +23,21 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<WebSocketProvider url="/api/v1/ws/server">
<StatusProvider>
<FilterProvider>
<App />
<Toaster
duration={1000}
toastOptions={{
classNames: {
default:
"w-fit rounded-full px-2.5 py-1.5 bg-neutral-100 border border-neutral-200 backdrop-blur-xl shadow-none",
},
}}
position="top-center"
className={"flex items-center justify-center"}
/>
<ReactQueryDevtools />
<TooltipProvider>
<App />
<Toaster
duration={1000}
toastOptions={{
classNames: {
default:
"w-fit rounded-full px-2.5 py-1.5 bg-neutral-100 border border-neutral-200 backdrop-blur-xl shadow-none",
},
}}
position="top-center"
className={"flex items-center justify-center"}
/>
<ReactQueryDevtools />
</TooltipProvider>
</FilterProvider>
</StatusProvider>
</WebSocketProvider>
Expand Down
7 changes: 6 additions & 1 deletion src/pages/Server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,12 @@ export default function Servers() {
setCurrentTab={setCurrentGroup}
/>
</section>
{showMap === "1" && <GlobalMap serverList={nezhaWsData?.servers || []} />}
{showMap === "1" && (
<GlobalMap
now={nezhaWsData.now}
serverList={nezhaWsData?.servers || []}
/>
)}
{showServices === "1" && <ServiceTracker />}
{inline === "1" && (
<section className="flex flex-col gap-2 overflow-x-scroll scrollbar-hidden mt-6">
Expand Down
6 changes: 3 additions & 3 deletions src/types/css.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
declare module '*.css' {
const css: { [key: string]: string }
export default css
declare module "*.css" {
const css: { [key: string]: string };
export default css;
}
1 change: 0 additions & 1 deletion src/types/nezha-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export interface NezhaServerHost {
swap_total: number;
arch: string;
boot_time: number;
country_code: string;
version: string;
}

Expand Down
2 changes: 1 addition & 1 deletion src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />

0 comments on commit 6712492

Please sign in to comment.