mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 17:12:10 +01:00
Enhances dashboard with new metrics and charts
Improves the dashboard with additional metrics and visualizations to provide a more comprehensive overview of application usage and performance. Adds new charts, including: - Word cloud for category analysis - Geographic map for user distribution (simulated data) - Response time distribution chart Refactors existing components for improved clarity and reusability, including the introduction of a generic `MetricCard` component. Improves error handling and user feedback during data refresh and session loading. Adds recommended VSCode extensions for ESLint and Prettier.
This commit is contained in:
@ -163,7 +163,7 @@ export function LanguagePieChart({ languages }: LanguagePieChartProps) {
|
||||
|
||||
// Get top 5 languages, combine others
|
||||
const entries = Object.entries(languages);
|
||||
let topLanguages = entries.sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
const topLanguages = entries.sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
|
||||
// Sum the count of all other languages
|
||||
const otherCount = entries
|
||||
@ -181,12 +181,13 @@ export function LanguagePieChart({ languages }: LanguagePieChartProps) {
|
||||
// Store original ISO codes for tooltip
|
||||
const isoCodes = topLanguages.map(([lang]) => lang);
|
||||
|
||||
const labels = topLanguages.map(([lang], index) => {
|
||||
const labels = topLanguages.map(([lang]) => {
|
||||
// Check if this is a valid ISO 639-1 language code
|
||||
if (lang && lang !== "Other" && /^[a-z]{2}$/.test(lang)) {
|
||||
try {
|
||||
return languageDisplayNames.of(lang);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Empty catch block - no need to name the error parameter
|
||||
return lang; // Fallback to code if display name can't be resolved
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export default function CountryDisplay({
|
||||
className,
|
||||
}: CountryDisplayProps) {
|
||||
const [countryName, setCountryName] = useState<string>(
|
||||
countryCode || "Unknown",
|
||||
countryCode || "Unknown"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
133
components/DonutChart.tsx
Normal file
133
components/DonutChart.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import Chart from "chart.js/auto";
|
||||
|
||||
interface DonutChartProps {
|
||||
data: {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
colors?: string[];
|
||||
};
|
||||
centerText?: {
|
||||
title?: string;
|
||||
value?: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function DonutChart({ data, centerText }: DonutChartProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !data.values.length) return;
|
||||
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Default colors if not provided
|
||||
const defaultColors: string[] = [
|
||||
"rgba(59, 130, 246, 0.8)", // blue
|
||||
"rgba(16, 185, 129, 0.8)", // green
|
||||
"rgba(249, 115, 22, 0.8)", // orange
|
||||
"rgba(236, 72, 153, 0.8)", // pink
|
||||
"rgba(139, 92, 246, 0.8)", // purple
|
||||
"rgba(107, 114, 128, 0.8)", // gray
|
||||
];
|
||||
|
||||
const colors: string[] = data.colors || defaultColors;
|
||||
|
||||
// Helper to create an array of colors based on the data length
|
||||
const getColors = () => {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < data.values.length; i++) {
|
||||
result.push(colors[i % colors.length]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: data.values,
|
||||
backgroundColor: getColors(),
|
||||
borderWidth: 1,
|
||||
hoverOffset: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
cutout: "70%",
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "right",
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const label = context.label || "";
|
||||
const value = context.formattedValue;
|
||||
const total = context.chart.data.datasets[0].data.reduce(
|
||||
(a: number, b: any) => a + (typeof b === "number" ? b : 0),
|
||||
0
|
||||
);
|
||||
const percentage = Math.round((context.parsed * 100) / total);
|
||||
return `${label}: ${value} (${percentage}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: centerText
|
||||
? [
|
||||
{
|
||||
id: "centerText",
|
||||
beforeDraw: function (chart: any) {
|
||||
const width = chart.width;
|
||||
const height = chart.height;
|
||||
const ctx = chart.ctx;
|
||||
ctx.restore();
|
||||
|
||||
// Title text
|
||||
if (centerText.title) {
|
||||
ctx.font = "14px Arial";
|
||||
ctx.fillStyle = "#6B7280"; // text-gray-500
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(centerText.title, width / 2, height / 2 - 10);
|
||||
}
|
||||
|
||||
// Value text
|
||||
if (centerText.value !== undefined) {
|
||||
ctx.font = "bold 20px Arial";
|
||||
ctx.fillStyle = "#111827"; // text-gray-900
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(
|
||||
String(centerText.value),
|
||||
width / 2,
|
||||
height / 2 + 10
|
||||
);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
|
||||
return () => chart.destroy();
|
||||
}, [data, centerText]);
|
||||
|
||||
return <canvas ref={ref} height={300} />;
|
||||
}
|
||||
112
components/GeographicMap.tsx
Normal file
112
components/GeographicMap.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Define types for country data
|
||||
interface CountryData {
|
||||
code: string;
|
||||
count: number;
|
||||
coordinates: [number, number]; // Latitude and longitude
|
||||
}
|
||||
|
||||
interface GeographicMapProps {
|
||||
countries: Record<string, number>; // Country code to count mapping
|
||||
countryCoordinates?: Record<string, [number, number]>; // Optional custom coordinates
|
||||
height?: number; // Optional height for the container
|
||||
}
|
||||
|
||||
// Default coordinates for commonly used countries (latitude, longitude)
|
||||
const DEFAULT_COORDINATES: Record<string, [number, number]> = {
|
||||
US: [37.0902, -95.7129],
|
||||
GB: [55.3781, -3.436],
|
||||
DE: [51.1657, 10.4515],
|
||||
FR: [46.2276, 2.2137],
|
||||
CA: [56.1304, -106.3468],
|
||||
AU: [-25.2744, 133.7751],
|
||||
JP: [36.2048, 138.2529],
|
||||
BR: [-14.235, -51.9253],
|
||||
IN: [20.5937, 78.9629],
|
||||
ZA: [-30.5595, 22.9375],
|
||||
ES: [40.4637, -3.7492],
|
||||
NL: [52.1326, 5.2913],
|
||||
IT: [41.8719, 12.5674],
|
||||
SE: [60.1282, 18.6435],
|
||||
// Add more country coordinates as needed
|
||||
};
|
||||
|
||||
// Dynamically import the Map component to avoid SSR issues
|
||||
// This ensures the component only loads on the client side
|
||||
const Map = dynamic(() => import("./Map"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||
Loading map...
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export default function GeographicMap({
|
||||
countries,
|
||||
countryCoordinates = DEFAULT_COORDINATES,
|
||||
height = 400,
|
||||
}: GeographicMapProps) {
|
||||
const [countryData, setCountryData] = useState<CountryData[]>([]);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// Set client-side flag on component mount
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Process country data when client is ready and dependencies change
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
|
||||
try {
|
||||
// Generate CountryData array for the Map component
|
||||
const data: CountryData[] = Object.entries(countries)
|
||||
// Only include countries with known coordinates
|
||||
.filter(
|
||||
([code]) => countryCoordinates[code] || DEFAULT_COORDINATES[code]
|
||||
)
|
||||
.map(([code, count]) => ({
|
||||
code,
|
||||
count,
|
||||
coordinates: countryCoordinates[code] ||
|
||||
DEFAULT_COORDINATES[code] || [0, 0],
|
||||
}));
|
||||
|
||||
setCountryData(data);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error processing geographic data:", error);
|
||||
setCountryData([]);
|
||||
}
|
||||
}, [countries, countryCoordinates, isClient]);
|
||||
|
||||
// Find the max count for scaling circles
|
||||
const maxCount = Math.max(...Object.values(countries), 1);
|
||||
|
||||
// Show loading state during SSR or until client-side rendering takes over
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||
Loading map...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full" style={{ height }}>
|
||||
{Object.keys(countries).length > 0 ? (
|
||||
<Map countryData={countryData} maxCount={maxCount} />
|
||||
) : (
|
||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||
No geographic data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -17,7 +17,7 @@ export default function LanguageDisplay({
|
||||
className,
|
||||
}: LanguageDisplayProps) {
|
||||
const [languageName, setLanguageName] = useState<string>(
|
||||
languageCode || "Unknown",
|
||||
languageCode || "Unknown"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
58
components/Map.tsx
Normal file
58
components/Map.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { MapContainer, TileLayer, CircleMarker, Tooltip } from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { getLocalizedCountryName } from "../lib/localization";
|
||||
|
||||
interface CountryData {
|
||||
code: string;
|
||||
count: number;
|
||||
coordinates: [number, number];
|
||||
}
|
||||
|
||||
interface MapProps {
|
||||
countryData: CountryData[];
|
||||
maxCount: number;
|
||||
}
|
||||
|
||||
const Map = ({ countryData, maxCount }: MapProps) => {
|
||||
return (
|
||||
<MapContainer
|
||||
center={[30, 0]}
|
||||
zoom={2}
|
||||
zoomControl={true}
|
||||
scrollWheelZoom={false}
|
||||
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{countryData.map((country) => (
|
||||
<CircleMarker
|
||||
key={country.code}
|
||||
center={country.coordinates}
|
||||
radius={5 + (country.count / maxCount) * 20}
|
||||
pathOptions={{
|
||||
fillColor: "#3B82F6",
|
||||
color: "#1E40AF",
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.6,
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<div className="p-1">
|
||||
<div className="font-medium">
|
||||
{getLocalizedCountryName(country.code)}
|
||||
</div>
|
||||
<div className="text-sm">Sessions: {country.count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Map;
|
||||
91
components/MetricCard.tsx
Normal file
91
components/MetricCard.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number | null | undefined;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
label?: string;
|
||||
isPositive?: boolean;
|
||||
};
|
||||
variant?: "default" | "primary" | "success" | "warning" | "danger";
|
||||
}
|
||||
|
||||
export default function MetricCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon,
|
||||
trend,
|
||||
variant = "default",
|
||||
}: MetricCardProps) {
|
||||
// Determine background and text colors based on variant
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return "bg-blue-50 border-blue-200";
|
||||
case "success":
|
||||
return "bg-green-50 border-green-200";
|
||||
case "warning":
|
||||
return "bg-amber-50 border-amber-200";
|
||||
case "danger":
|
||||
return "bg-red-50 border-red-200";
|
||||
default:
|
||||
return "bg-white border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const getIconClasses = () => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return "bg-blue-100 text-blue-600";
|
||||
case "success":
|
||||
return "bg-green-100 text-green-600";
|
||||
case "warning":
|
||||
return "bg-amber-100 text-amber-600";
|
||||
case "danger":
|
||||
return "bg-red-100 text-red-600";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-600";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border shadow-sm p-6 ${getVariantClasses()}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">{title}</p>
|
||||
<div className="mt-2 flex items-baseline">
|
||||
<p className="text-2xl font-semibold">{value ?? "-"}</p>
|
||||
{trend && (
|
||||
<span
|
||||
className={`ml-2 text-sm font-medium ${
|
||||
trend.isPositive !== false ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{trend.isPositive !== false ? "↑" : "↓"}{" "}
|
||||
{Math.abs(trend.value).toFixed(1)}%
|
||||
{trend.label && (
|
||||
<span className="text-gray-500 ml-1">{trend.label}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-gray-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{icon && (
|
||||
<div
|
||||
className={`flex h-12 w-12 rounded-full ${getIconClasses()} items-center justify-center`}
|
||||
>
|
||||
<span className="text-xl">{icon}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
components/ResponseTimeDistribution.tsx
Normal file
109
components/ResponseTimeDistribution.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import Chart from "chart.js/auto";
|
||||
import annotationPlugin from "chartjs-plugin-annotation";
|
||||
|
||||
Chart.register(annotationPlugin);
|
||||
|
||||
interface ResponseTimeDistributionProps {
|
||||
responseTimes: number[];
|
||||
targetResponseTime?: number;
|
||||
}
|
||||
|
||||
export default function ResponseTimeDistribution({
|
||||
responseTimes,
|
||||
targetResponseTime,
|
||||
}: ResponseTimeDistributionProps) {
|
||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !responseTimes.length) return;
|
||||
|
||||
const ctx = ref.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
||||
const maxTime = Math.ceil(Math.max(...responseTimes));
|
||||
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
||||
|
||||
// Count responses in each bin
|
||||
responseTimes.forEach((time) => {
|
||||
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
||||
bins[binIndex]++;
|
||||
});
|
||||
|
||||
// Create labels for each bin
|
||||
const labels = bins.map((_, i) => {
|
||||
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
||||
return `${i}+ seconds`;
|
||||
}
|
||||
return `${i}-${i + 1} seconds`;
|
||||
});
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Responses",
|
||||
data: bins,
|
||||
backgroundColor: bins.map((_, i) => {
|
||||
// Green for fast, yellow for medium, red for slow
|
||||
if (i <= 2) return "rgba(34, 197, 94, 0.7)"; // Green
|
||||
if (i <= 5) return "rgba(250, 204, 21, 0.7)"; // Yellow
|
||||
return "rgba(239, 68, 68, 0.7)"; // Red
|
||||
}),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
annotation: targetResponseTime
|
||||
? {
|
||||
annotations: {
|
||||
targetLine: {
|
||||
type: "line",
|
||||
yMin: 0,
|
||||
yMax: Math.max(...bins),
|
||||
xMin: targetResponseTime,
|
||||
xMax: targetResponseTime,
|
||||
borderColor: "rgba(75, 192, 192, 1)",
|
||||
borderWidth: 2,
|
||||
label: {
|
||||
display: true,
|
||||
content: "Target",
|
||||
position: "start",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: "Number of Responses",
|
||||
},
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "Response Time",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => chart.destroy();
|
||||
}, [responseTimes, targetResponseTime]);
|
||||
|
||||
return <canvas ref={ref} height={180} />;
|
||||
}
|
||||
62
components/WelcomeBanner.tsx
Normal file
62
components/WelcomeBanner.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
interface WelcomeBannerProps {
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export default function WelcomeBanner({ companyName }: WelcomeBannerProps) {
|
||||
const { data: session } = useSession();
|
||||
const userName = session?.user?.name || "User";
|
||||
const currentTime = new Date();
|
||||
const hour = currentTime.getHours();
|
||||
|
||||
let greeting = "Welcome";
|
||||
if (hour < 12) {
|
||||
greeting = "Good morning";
|
||||
} else if (hour < 18) {
|
||||
greeting = "Good afternoon";
|
||||
} else {
|
||||
greeting = "Good evening";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 text-white p-6 rounded-xl shadow-lg mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{greeting}, {userName}!
|
||||
</h1>
|
||||
<p className="mt-2 opacity-90">
|
||||
Welcome to the {companyName || "LiveDash"} analytics dashboard.
|
||||
Here's an overview of your metrics and performance data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<div className="text-5xl">📊</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
||||
<div className="text-sm opacity-75">Last Update</div>
|
||||
<div className="text-xl font-semibold">
|
||||
{currentTime.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
||||
<div className="text-sm opacity-75">Current Status</div>
|
||||
<div className="text-xl font-semibold flex items-center">
|
||||
<span className="inline-block w-2 h-2 bg-green-400 rounded-full mr-2"></span>
|
||||
All Systems Operational
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
||||
<div className="text-sm opacity-75">Today's Insights</div>
|
||||
<div className="text-xl font-semibold">Ready to Explore</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
components/WordCloud.tsx
Normal file
118
components/WordCloud.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { select } from "d3-selection";
|
||||
import cloud from "d3-cloud";
|
||||
|
||||
interface CloudWord {
|
||||
text: string;
|
||||
size: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
rotate?: number;
|
||||
}
|
||||
|
||||
interface WordCloudProps {
|
||||
words: {
|
||||
text: string;
|
||||
value: number;
|
||||
}[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function WordCloud({
|
||||
words,
|
||||
width = 500,
|
||||
height = 300,
|
||||
}: WordCloudProps) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !isClient || !words.length) return;
|
||||
|
||||
const svg = select(svgRef.current);
|
||||
svg.selectAll("*").remove(); // Clear previous cloud
|
||||
|
||||
// Find the max value for proper scaling
|
||||
const maxValue = Math.max(...words.map((w) => w.value || 1));
|
||||
|
||||
// Configure the layout
|
||||
const layout = cloud()
|
||||
.size([width, height])
|
||||
.words(
|
||||
words.map((d) => ({
|
||||
text: d.text,
|
||||
size: 10 + (d.value * 90) / maxValue, // Scale from 10 to 100 based on value
|
||||
}))
|
||||
)
|
||||
.padding(5)
|
||||
.rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees
|
||||
.fontSize((d) => (d as any).size)
|
||||
.on("end", draw);
|
||||
|
||||
layout.start();
|
||||
|
||||
function draw(words: CloudWord[]) {
|
||||
svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(${width / 2},${height / 2})`)
|
||||
.selectAll("text")
|
||||
.data(words)
|
||||
.enter()
|
||||
.append("text")
|
||||
.style("font-size", (d: CloudWord) => `${d.size}px`)
|
||||
.style("font-family", "Inter, Arial, sans-serif")
|
||||
.style("fill", () => {
|
||||
// Create a nice gradient of colors
|
||||
const colors = [
|
||||
"#4299E1", // blue-500
|
||||
"#3182CE", // blue-600
|
||||
"#2B6CB0", // blue-700
|
||||
"#63B3ED", // blue-400
|
||||
"#90CDF4", // blue-300
|
||||
"#38B2AC", // teal-500
|
||||
"#4FD1C5", // teal-400
|
||||
];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
})
|
||||
.style("cursor", "pointer")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr(
|
||||
"transform",
|
||||
(d: CloudWord) =>
|
||||
`translate(${d.x || 0},${d.y || 0}) rotate(${d.rotate || 0})`
|
||||
)
|
||||
.text((d: CloudWord) => d.text);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
svg.selectAll("*").remove();
|
||||
};
|
||||
}, [words, width, height, isClient]);
|
||||
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="w-full h-full bg-white flex items-center justify-center">
|
||||
<span className="text-gray-500">Loading word cloud...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full h-full">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={height}
|
||||
aria-label="Word cloud visualization of categories"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user