Enhance chart display
This commit is contained in:
@@ -102,129 +102,56 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
return `${Math.round(lat * 100) / 100},${Math.round(lng * 100) / 100}`;
|
||||
};
|
||||
|
||||
const processChartData = (locations: Location[]): ChartData[] => {
|
||||
// 过滤有效数据
|
||||
const processChartDataOptimized = (locations: Location[]): ChartData[] => {
|
||||
const validLocations = locations.filter((loc) => loc.createdAt);
|
||||
if (validLocations.length === 0) return [];
|
||||
|
||||
// 获取时间范围
|
||||
// 如果数据量很少,直接按原始时间点展示
|
||||
if (validLocations.length <= 10) {
|
||||
return validLocations.map((loc, index) => ({
|
||||
time: format(new Date(loc.createdAt!), "HH:mm:ss"),
|
||||
count: loc.count,
|
||||
}));
|
||||
}
|
||||
|
||||
// 否则使用智能分组
|
||||
const dates = validLocations.map((loc) => new Date(loc.createdAt!));
|
||||
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
|
||||
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
|
||||
|
||||
// 根据时间跨度选择分组策略
|
||||
const totalMinutes = differenceInMinutes(maxDate, minDate);
|
||||
const totalHours = differenceInHours(maxDate, minDate);
|
||||
const totalDays = differenceInDays(maxDate, minDate);
|
||||
|
||||
let groupByFn: (date: Date) => Date;
|
||||
let formatFn: (date: Date) => string;
|
||||
let intervalFn: (date: Date, interval: number) => Date;
|
||||
let interval: number;
|
||||
// 根据数据量和时间跨度动态调整分组
|
||||
const targetGroups = Math.min(validLocations.length, 20); // 目标分组数量
|
||||
let groupMinutes: number;
|
||||
|
||||
// 30分钟内:按1分钟分组
|
||||
if (totalMinutes <= 30) {
|
||||
groupByFn = startOfMinute;
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 1;
|
||||
} else if (totalMinutes <= 60) {
|
||||
// 1小时内:按2分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 2) * 2;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 2;
|
||||
} else if (totalHours <= 2) {
|
||||
// 2小时内:按4分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 4) * 4;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 4;
|
||||
} else if (totalHours <= 6) {
|
||||
// 6小时内:按12分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 12) * 12;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 12;
|
||||
} else if (totalHours <= 12) {
|
||||
// 12小时内:按24分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 24) * 24;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 24;
|
||||
} else if (totalHours <= 24) {
|
||||
// 24小时内:按48分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 48) * 48;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 48;
|
||||
} else if (totalDays <= 7) {
|
||||
// 7天内:按天分组
|
||||
groupByFn = startOfDay;
|
||||
formatFn = (date) => format(date, "MM-dd");
|
||||
intervalFn = addHours;
|
||||
interval = 24;
|
||||
if (totalMinutes <= 60) {
|
||||
groupMinutes = Math.max(1, Math.ceil(totalMinutes / targetGroups));
|
||||
} else {
|
||||
// 更长时间:按天分组
|
||||
groupByFn = startOfDay;
|
||||
formatFn = (date) => format(date, "MM-dd");
|
||||
intervalFn = addHours;
|
||||
interval = 24;
|
||||
groupMinutes = Math.max(5, Math.ceil(totalMinutes / targetGroups));
|
||||
}
|
||||
|
||||
// 分组聚合数据
|
||||
const groupedData = new Map<string, number>();
|
||||
const groupByFn = (date: Date) => {
|
||||
const minutes =
|
||||
Math.floor(date.getMinutes() / groupMinutes) * groupMinutes;
|
||||
const grouped = new Date(date);
|
||||
grouped.setMinutes(minutes, 0, 0);
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const groupedData = new Map<string, number>();
|
||||
validLocations.forEach((loc) => {
|
||||
const date = new Date(loc.createdAt!);
|
||||
const groupedDate = groupByFn(date);
|
||||
const key = groupedDate.getTime().toString();
|
||||
|
||||
groupedData.set(key, (groupedData.get(key) || 0) + loc.count);
|
||||
});
|
||||
|
||||
// 填充时间间隔,确保连续性
|
||||
const result: ChartData[] = [];
|
||||
const startGroup = groupByFn(minDate);
|
||||
const endGroup = groupByFn(maxDate);
|
||||
|
||||
let current = startGroup;
|
||||
// 过滤掉count为0 的数据
|
||||
while (current <= endGroup) {
|
||||
const key = current.getTime().toString();
|
||||
result.push({
|
||||
time: formatFn(current),
|
||||
count: groupedData.get(key) || 0,
|
||||
});
|
||||
current = intervalFn(current, interval);
|
||||
}
|
||||
|
||||
return result;
|
||||
return Array.from(groupedData.entries())
|
||||
.sort(([a], [b]) => parseInt(a) - parseInt(b))
|
||||
.map(([key, count]) => ({
|
||||
time: format(new Date(parseInt(key)), "HH:mm"),
|
||||
count: count,
|
||||
}));
|
||||
};
|
||||
|
||||
const appendLocationData = (
|
||||
@@ -276,7 +203,7 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
count: Math.max(0.1, loc.count / Math.max(totalCount, 1)),
|
||||
}));
|
||||
|
||||
const chartData = processChartData(updatedLocations);
|
||||
const chartData = processChartDataOptimized(updatedLocations);
|
||||
|
||||
return {
|
||||
locations: normalizedLocations,
|
||||
@@ -488,13 +415,13 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<RealtimeTimePicker
|
||||
timeRange={timeRange}
|
||||
setTimeRange={handleTimeRangeChange}
|
||||
/>
|
||||
<div className="sm:relative sm:p-4">
|
||||
<RealtimeTimePicker
|
||||
timeRange={timeRange}
|
||||
setTimeRange={handleTimeRangeChange}
|
||||
/>
|
||||
<RealtimeChart
|
||||
className="left-0 top-0 z-10 rounded-t-none text-left sm:absolute"
|
||||
className="left-0 top-9 z-10 rounded-t-none text-left sm:absolute"
|
||||
chartData={chartData}
|
||||
totalClicks={stats.totalClicks}
|
||||
/>
|
||||
@@ -506,7 +433,7 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
setHandleTrafficEvent={(fn) => (handleTrafficEventRef.current = fn)}
|
||||
/>
|
||||
<RealtimeLogs
|
||||
className="-top-9 right-0 z-10 sm:absolute"
|
||||
className="right-0 top-0 z-10 sm:absolute"
|
||||
locations={locations}
|
||||
/>
|
||||
</div>
|
||||
@@ -523,7 +450,7 @@ export function RealtimeTimePicker({
|
||||
}) {
|
||||
return (
|
||||
<Select onValueChange={setTimeRange} name="time range" value={timeRange}>
|
||||
<SelectTrigger className="rounded-b-none border-b-0 sm:w-[326px]">
|
||||
<SelectTrigger className="left-0 top-0 z-10 h-9 rounded-b-none border-b-0 bg-transparent text-left backdrop-blur-2xl sm:absolute sm:w-[326px]">
|
||||
<SelectValue placeholder="Select a time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -36,7 +36,6 @@ export const RealtimeChart = ({
|
||||
return Math.ceil(dataLength / 6);
|
||||
};
|
||||
|
||||
// console.log("chartData", chartData);
|
||||
const filteredChartData = chartData.filter((item, index) => {
|
||||
return item.count !== 0 || index === chartData.length - 1;
|
||||
});
|
||||
@@ -50,7 +49,6 @@ export const RealtimeChart = ({
|
||||
<Icons.mousePointerClick className="ml-auto size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mb-2 text-lg font-semibold">{totalClicks}</p>
|
||||
{/* <ResponsiveContainer ></ResponsiveContainer> */}
|
||||
<BarChart
|
||||
width={300}
|
||||
height={200}
|
||||
@@ -68,7 +66,7 @@ export const RealtimeChart = ({
|
||||
type="category"
|
||||
scale="point"
|
||||
padding={{ left: 14, right: 20 }}
|
||||
tickFormatter={(value) => value.split(" ")[1]}
|
||||
tickFormatter={(value) => value}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, "dataMax"]}
|
||||
@@ -94,7 +92,7 @@ export const RealtimeChart = ({
|
||||
dataKey="count"
|
||||
fill="#2d9af9"
|
||||
radius={[1, 1, 0, 0]}
|
||||
maxBarSize={20}
|
||||
maxBarSize={40}
|
||||
/>
|
||||
</BarChart>
|
||||
</div>
|
||||
|
||||
@@ -123,8 +123,8 @@ export default function RealtimeGlobe({
|
||||
globe = new Globe(container)
|
||||
.width(wrapperWidth)
|
||||
.height(wrapperWidth > 728 ? wrapperWidth * 0.9 : wrapperWidth)
|
||||
.globeOffset([0, -130])
|
||||
.atmosphereColor("rgba(170, 170, 200, 0.8)")
|
||||
.globeOffset([0, -80])
|
||||
.atmosphereColor("rgba(170, 170, 200, 0.7)")
|
||||
.backgroundColor("rgba(0,0,0,0)")
|
||||
.globeMaterial(
|
||||
new MeshPhongMaterial({
|
||||
@@ -295,7 +295,10 @@ export default function RealtimeGlobe({
|
||||
}, [cleanup]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative max-h-screen overflow-hidden">
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative -mt-8 max-h-screen overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={globeRef}
|
||||
className="flex justify-center"
|
||||
|
||||
@@ -109,7 +109,7 @@ export async function GET(request: NextRequest) {
|
||||
if (item.latitude && item.longitude) {
|
||||
const lat = Math.round(Number(item.latitude) * 100) / 100;
|
||||
const lng = Math.round(Number(item.longitude) * 100) / 100;
|
||||
const key = `${lat},${lng}`;
|
||||
const key = `${lat},${lng},${item.createdAt},${item.userUrl.url},${item.userUrl.prefix}`;
|
||||
|
||||
if (locationMap.has(key)) {
|
||||
const existing = locationMap.get(key)!;
|
||||
@@ -148,10 +148,6 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
const uniqueLocations = aggregatedData.length;
|
||||
|
||||
// console.log(
|
||||
// `Fetched ${rawData.length} records, aggregated to ${uniqueLocations} locations, total clicks: ${totalClicks}`,
|
||||
// );
|
||||
|
||||
return NextResponse.json({
|
||||
data: aggregatedData,
|
||||
total: uniqueLocations,
|
||||
@@ -180,9 +176,6 @@ export async function GET(request: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
// finally {
|
||||
// await prisma.$disconnect();
|
||||
// }
|
||||
}
|
||||
|
||||
// POST endpoint remains the same
|
||||
@@ -272,7 +265,4 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
// finally {
|
||||
// await prisma.$disconnect();
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
import CountUp from "../dashboard/count-up";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user