feat: add website visitor map

This commit is contained in:
moonrailgun 2024-01-30 00:28:37 +08:00
parent e97d2f7c17
commit 5c633ae38c
7 changed files with 1482 additions and 13 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,95 @@
import type { LarkMapProps, PointLayerProps } from '@antv/larkmap';
import { FullscreenControl, LarkMap, PointLayer } from '@antv/larkmap';
import React from 'react';
import { useSettingsStore } from '../../store/settings';
import { useGlobalConfig } from '../../hooks/useConfig';
import { trpc } from '../../api/trpc';
import { useCurrentWorkspaceId } from '../../store/user';
import { useGlobalRangeDate } from '../../hooks/useGlobalRangeDate';
const layerOptions: Omit<PointLayerProps, 'source'> = {
autoFit: true,
shape: 'circle',
size: 5,
blend: 'additive',
color: {
field: 'count',
value: [
'rgb(102,37,6)',
'rgb(153,52,4)',
'rgb(204,76,2)',
'rgb(236,112,20)',
'rgb(254,153,41)',
'rgb(254,196,79)',
'rgb(254,227,145)',
],
},
};
function useMapConfig(mapType: 'Mapbox' | 'Gaode' = 'Mapbox'): LarkMapProps {
const { amapToken, mapboxToken } = useGlobalConfig();
const colorScheme = useSettingsStore((state) => state.colorScheme);
const baseOption: LarkMapProps['mapOptions'] = {
center: [120.210792, 30.246026],
zoom: 0,
};
if (mapType === 'Gaode') {
return {
mapType: 'Gaode',
mapOptions: {
...baseOption,
style:
colorScheme === 'light'
? 'amap://styles/light'
: 'amap://styles/dark',
token: amapToken,
},
logoVisible: false,
};
} else {
return {
mapType: 'Mapbox',
mapOptions: {
...baseOption,
style: colorScheme === 'light' ? 'light' : 'dark',
token: mapboxToken,
},
logoVisible: false,
};
}
}
interface WebsiteVisitorMapProps {
websiteId: string;
}
export const WebsiteVisitorMap: React.FC<WebsiteVisitorMapProps> = React.memo(
(props) => {
const config = useMapConfig();
const workspaceId = useCurrentWorkspaceId();
const { startDate, endDate } = useGlobalRangeDate();
const startAt = startDate.valueOf();
const endAt = endDate.valueOf();
const { data } = trpc.website.geoStats.useQuery({
workspaceId,
websiteId: props.websiteId,
startAt,
endAt,
});
const source: PointLayerProps['source'] = {
data: data ?? [],
parser: { type: 'json', x: 'longitude', y: 'latitude' },
};
return (
<LarkMap {...config} style={{ height: '60vh' }}>
<FullscreenControl />
<PointLayer {...layerOptions} source={source} />
</LarkMap>
);
}
);
WebsiteVisitorMap.displayName = 'WebsiteVisitorMap';

View File

@ -14,6 +14,8 @@
"dependencies": {
"@ant-design/charts": "^1.4.2",
"@ant-design/icons": "^5.2.6",
"@antv/l7": "^2.20.14",
"@antv/larkmap": "^1.4.13",
"@loadable/component": "^5.16.3",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "4.33.0",
@ -57,6 +59,7 @@
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.0.4",
"autoprefixer": "^10.4.16",
"less": "^4.2.0",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"vite": "^5.0.12",

View File

@ -0,0 +1,40 @@
import React from 'react';
import { useParams } from 'react-router';
import { trpc } from '../../api/trpc';
import { ErrorTip } from '../../components/ErrorTip';
import { Loading } from '../../components/Loading';
import { NotFoundTip } from '../../components/NotFoundTip';
import { useCurrentWorkspaceId } from '../../store/user';
import { WebsiteVisitorMap } from '../../components/website/WebsiteVisitorMap';
import { DateFilter } from '../../components/DateFilter';
export const WebsiteVisitorMapPage: React.FC = React.memo(() => {
const { websiteId } = useParams();
const workspaceId = useCurrentWorkspaceId();
const { data: website, isLoading } = trpc.website.info.useQuery({
workspaceId,
websiteId: websiteId!,
});
if (!websiteId) {
return <ErrorTip />;
}
if (isLoading) {
return <Loading />;
}
if (!website) {
return <NotFoundTip />;
}
return (
<div className="py-6">
<div className="pb-2 flex justify-end">
<DateFilter />
</div>
<WebsiteVisitorMap websiteId={websiteId} />
</div>
);
});
WebsiteVisitorMapPage.displayName = 'WebsiteVisitorMapPage';

View File

@ -2,6 +2,7 @@ import React from 'react';
import { Route, Routes } from 'react-router';
import { WebsiteList } from '../../components/website/WebsiteList';
import { WebsiteDetail } from './Detail';
import { WebsiteVisitorMapPage } from './Map';
export const WebsitePage: React.FC = React.memo(() => {
return (
@ -9,6 +10,7 @@ export const WebsitePage: React.FC = React.memo(() => {
<Routes>
<Route path="/" element={<WebsiteList />} />
<Route path="/:websiteId" element={<WebsiteDetail />} />
<Route path="/:websiteId/map" element={<WebsiteVisitorMapPage />} />
</Routes>
</div>
);

View File

@ -17,12 +17,16 @@ export const globalRouter = router({
z.object({
allowRegister: z.boolean(),
websiteId: z.string().optional(),
amapToken: z.string().optional(),
mapboxToken: z.string().optional(),
})
)
.query(async ({ input }) => {
return {
allowRegister: checkEnvTrusty(process.env.ALLOW_REGISTER),
websiteId: process.env.WEBSITE_ID,
amapToken: process.env.AMAP_TOKEN,
mapboxToken: process.env.MAPBOX_TOKEN,
};
}),
});

View File

@ -187,6 +187,58 @@ export const websiteRouter = router({
return websiteStatsSchema.parse(stats);
}),
geoStats: workspaceProcedure
.meta(
buildWebsiteOpenapi({
method: 'GET',
path: '/geoStats',
})
)
.input(
z.object({
websiteId: z.string(),
startAt: z.number(),
endAt: z.number(),
})
)
.output(
z.array(
z.object({
longitude: z.number(),
latitude: z.number(),
count: z.number(),
})
)
)
.query(async ({ input }) => {
const { websiteId, startAt, endAt } = input;
const res = await prisma.websiteSession.groupBy({
by: ['longitude', 'latitude'],
where: {
websiteId,
longitude: { not: null },
latitude: { not: null },
createdAt: {
gt: new Date(startAt),
lte: new Date(endAt),
},
},
_count: {
_all: true,
},
});
return res
.filter((item) => item.longitude !== null && item.latitude !== null)
.map((item) => {
return {
longitude: item.longitude!,
latitude: item.latitude!,
count: item._count._all,
};
});
}),
metrics: workspaceProcedure
.meta(
buildWebsiteOpenapi({