feat: add website visitor map
This commit is contained in:
parent
e97d2f7c17
commit
5c633ae38c
1299
pnpm-lock.yaml
1299
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
95
src/client/components/website/WebsiteVisitorMap.tsx
Normal file
95
src/client/components/website/WebsiteVisitorMap.tsx
Normal 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';
|
@ -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",
|
||||
|
40
src/client/pages/Website/Map.tsx
Normal file
40
src/client/pages/Website/Map.tsx
Normal 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';
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
@ -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({
|
||||
|
Loading…
Reference in New Issue
Block a user