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": {
|
"dependencies": {
|
||||||
"@ant-design/charts": "^1.4.2",
|
"@ant-design/charts": "^1.4.2",
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
"@antv/l7": "^2.20.14",
|
||||||
|
"@antv/larkmap": "^1.4.13",
|
||||||
"@loadable/component": "^5.16.3",
|
"@loadable/component": "^5.16.3",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@tanstack/react-query": "4.33.0",
|
"@tanstack/react-query": "4.33.0",
|
||||||
@ -57,6 +59,7 @@
|
|||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@vitejs/plugin-react": "^4.0.4",
|
"@vitejs/plugin-react": "^4.0.4",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
|
"less": "^4.2.0",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.3.5",
|
"tailwindcss": "^3.3.5",
|
||||||
"vite": "^5.0.12",
|
"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 { Route, Routes } from 'react-router';
|
||||||
import { WebsiteList } from '../../components/website/WebsiteList';
|
import { WebsiteList } from '../../components/website/WebsiteList';
|
||||||
import { WebsiteDetail } from './Detail';
|
import { WebsiteDetail } from './Detail';
|
||||||
|
import { WebsiteVisitorMapPage } from './Map';
|
||||||
|
|
||||||
export const WebsitePage: React.FC = React.memo(() => {
|
export const WebsitePage: React.FC = React.memo(() => {
|
||||||
return (
|
return (
|
||||||
@ -9,6 +10,7 @@ export const WebsitePage: React.FC = React.memo(() => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<WebsiteList />} />
|
<Route path="/" element={<WebsiteList />} />
|
||||||
<Route path="/:websiteId" element={<WebsiteDetail />} />
|
<Route path="/:websiteId" element={<WebsiteDetail />} />
|
||||||
|
<Route path="/:websiteId/map" element={<WebsiteVisitorMapPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -17,12 +17,16 @@ export const globalRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
allowRegister: z.boolean(),
|
allowRegister: z.boolean(),
|
||||||
websiteId: z.string().optional(),
|
websiteId: z.string().optional(),
|
||||||
|
amapToken: z.string().optional(),
|
||||||
|
mapboxToken: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return {
|
return {
|
||||||
allowRegister: checkEnvTrusty(process.env.ALLOW_REGISTER),
|
allowRegister: checkEnvTrusty(process.env.ALLOW_REGISTER),
|
||||||
websiteId: process.env.WEBSITE_ID,
|
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);
|
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
|
metrics: workspaceProcedure
|
||||||
.meta(
|
.meta(
|
||||||
buildWebsiteOpenapi({
|
buildWebsiteOpenapi({
|
||||||
|
Loading…
Reference in New Issue
Block a user