react框架ice和ant背景图浮层显示坐标定位锚点世界地图数据动画滚动显示
文件index.module.css
.companiesCard {
:global {
.ant-tag {
cursor: pointer;
}
}
}
.workforceCompanyCard {
:global {
.ant-pro-card-body {
padding-inline: 10px;
padding-block: 10px;
}
.ant-statistic-title {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 71.5px;
}
.ant-pro-card-statistic-content .ant-statistic-content-value-int {
font-size: 18px;
}
}
}
.workforceExcludeTitle {
:global {
.ant-statistic-title {
width: auto;
}
}
}
.title {
margin-block-start: 0.5em;
margin-block-end: 0.5em;
text-align: center;
font-size: 2em;
}
.chartButtons {
text-align: center;
}
.countryButtons {
margin-block-start: 1em;
:global {
.china-region {
background-color: #e57373;
span {
color: #fff;
}
}
.china-region:hover {
background-color: #ef5350 !important;
span {
color: #fff;
}
}
.southeast-asia-region {
background-color: #ba68c8;
span {
color: #fff;
}
}
.southeast-asia-region:hover {
background-color: #ab47bc !important;
span {
color: #fff;
}
}
.south-asia-region {
background-color: #81c784;
span {
color: #fff;
}
}
.south-asia-region:hover {
background-color: #66bb6a !important;
span {
color: #fff;
}
}
.america-region {
background-color: #4fc3f7;
span {
color: #fff;
}
}
.america-region:hover {
background-color: #29b6f6 !important;
span {
color: #fff;
}
}
.europe-region {
background-color: #ffd54f;
span {
color: #fff;
}
}
.europe-region:hover {
background-color: #ffca28 !important;
span {
color: #fff;
}
}
}
}
.mapContainer {
position: relative;
width: 100%;
height: 0;
padding-bottom: 50%; /* 保持 2:1 的宽高比 (600/1200=0.5) */
}
.worldMap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.annotation {
position: absolute;
transform: translate(-50%, -50%); /* 使标注点居中 */
cursor: pointer;
}
.text {
font-size: 10px;
white-space: nowrap;
padding: 2px;
color: #e4007f;
}
.text2 {
font-size: 16px;
white-space: nowrap;
padding: 2px;
color: #000000;
font-weight: bold;
}
.tooltip {
position: absolute;
left: 100%;
top: 0;
margin-left: 8px;
background: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 8px;
min-width: 120px;
z-index: 10;
}
.tooltipItem {
display: flex;
justify-content: space-between;
padding: 2px;
font-size: 11px;
white-space: nowrap;
}
.tooltipItem:not(:last-child) {
border-bottom: 1px solid #f0f0f0;
}
文件NumberRoller.tsx
import React, { useState, useEffect } from 'react';
const NumberRoller = ({ value, duration = 1000 }) => {
const [displayValue, setDisplayValue] = useState(0);
useEffect(() => {
let start = 0;
const end = parseFloat(value) || 0;
const increment = end / (duration / 16); // 假设60fps
const timer = setInterval(() => {
start += increment;
if (start >= end) {
start = end;
clearInterval(timer);
}
setDisplayValue(Math.round(start * 10) / 10); // 保留一位小数
}, 16);
return () => clearInterval(timer);
}, [value, duration]);
return <span>{displayValue}%</span>;
};
export default NumberRoller;
文件index.tsx
import { ComposableMap, Geographies, Geography } from 'react-simple-maps';
import { ProCard, StatisticCard } from '@ant-design/pro-components';
import { Row, Col, Button, Modal, Popover, Space, Spin, Tag } from 'antd';
import { BankOutlined, CompassOutlined, GlobalOutlined } from '@ant-design/icons';
import { useContext, useEffect, useState } from 'react';
import { useRequest, defineDataLoader, request } from 'ice';
import styles from './index.module.css';
import countryGeoJSON from './ne_110m_admin_0_countries.json';
import NumberRoller from './NumberRoller';
import OrgCharts from '@/components/OrgCharts';
import { ComponentLocaleContext } from '@/contexts/ComponentLocale/ComponentLocaleContext';
import useResponsive from '@/hooks/useResponsive';
const Index = () => {
const { __ } = useContext(ComponentLocaleContext);
const { isDesktopOrAbove } = useResponsive();
const [highlightCountries, setHighlightCountries] = useState<string[]>([]);
const [previewModoalOpen, setPreviewModoalOpen] = useState(false);
const [chartId, setChartId] = useState(null);
const initRequest = useRequest({
url: '/api/admin/organizational-chart-entities',
params: {
pagination: false,
},
});
const workforceRequest = useRequest({
url: '/api/user/monthly-reports/latest-workforce',
params: {
pagination: false,
},
});
useEffect(() => {
initRequest.request();
workforceRequest.request();
return () => {
initRequest.cancel();
workforceRequest.cancel();
};
}, []);
const data = initRequest?.data?.data;
const workforceData = workforceRequest?.data?.data;
// 世界地图
// 假设地图图片原始尺寸为 1200x600px
const mapWidth = 1200;
const mapHeight = 600;
// 定义标注点的位置和文字内容(使用百分比)
const points = [
{
id: 11,
x: (464 / mapWidth) * 100, // 大中华区
y: (215 / mapHeight) * 100,
text: workforceData?.area_per['1'],
details: [
{ name: '浦东', value: '15%' },
{ name: '徐汇', value: '8%' },
],
},
{
id: 12,
x: (425 / mapWidth) * 100, // 东南亚
y: (357 / mapHeight) * 100,
text: workforceData?.area_per['2'],
details: [],
},
{
id: 13,
x: (334 / mapWidth) * 100, // 南亚区
y: (270 / mapHeight) * 100,
text: workforceData?.area_per['3'],
details: [],
},
{
id: 14,
x: (921 / mapWidth) * 100, // 美洲区
y: (155 / mapHeight) * 100,
text: workforceData?.area_per['4'],
details: [],
},
{
id: 15,
x: (225 / mapWidth) * 100, // 欧洲区
y: (152 / mapHeight) * 100,
text: workforceData?.area_per['5'],
details: [],
},
];
const pointsText = [
{
id: 21,
x: (512 / mapWidth) * 100,
y: (245 / mapHeight) * 100,
text: __('title.greater_china_chart'),
details: [],
},
{
id: 22,
x: (490 / mapWidth) * 100, // 东南亚
y: (377 / mapHeight) * 100,
text: __('title.southeast_asia_chart'),
details: [],
},
{
id: 23,
x: (313 / mapWidth) * 100, // 南亚区
y: (285 / mapHeight) * 100,
text: __('title.south_asia_chart'),
details: [],
},
{
id: 24,
x: (857 / mapWidth) * 100, // 美洲区
y: (200 / mapHeight) * 100,
text: __('title.americas_chart'),
details: [],
},
{
id: 25,
x: (210 / mapWidth) * 100, // 欧洲区
y: (175 / mapHeight) * 100,
text: __('title.europe_chart'),
details: [],
},
];
const [hoveredPoint, setHoveredPoint] = useState(null); // 点击后显示详细的框
// 世界地图 END
const handleButtonHover = (type: string) => {
switch (type) {
case 'greater-china':
setHighlightCountries(['CHN', 'HKG', 'MAC', 'TWN']);
break;
case 'south-asia':
setHighlightCountries(['IND', 'BGD']);
break;
case 'americas':
setHighlightCountries(['USA', 'GTM']);
break;
case 'southeast-asia':
setHighlightCountries(['VNM', 'KHM', 'THA', 'IDN']);
break;
case 'europe':
setHighlightCountries(['TUR', 'ESP', 'GBR', 'DEU', 'ARE']);
break;
default:
setHighlightCountries([]);
}
};
const openChart = (id) => {
const chart = (data || []).find((item) => item.id == id && item.active_chart_id !== 0);
if (chart) {
setPreviewModoalOpen(true);
setChartId(chart.active_chart_id);
}
};
const handleButtonClick = (type: string) => {
switch (type) {
case 'group':
openChart(1);
break;
case 'center':
openChart(2);
break;
case 'greater-china':
openChart(3);
break;
case 'southeast-asia':
openChart(4);
break;
case 'south-asia':
openChart(5);
break;
case 'americas':
openChart(6);
break;
case 'europe':
openChart(7);
break;
default:
}
};
const clearHighlight = () => {
setHighlightCountries([]);
};
const getWorkforceCountForCompany = (companyId) => {
return workforceData?.per_company.find((item) => item.company_id == companyId)?.total_workforce_count || 0;
};
const getHoverContent = (companyId) => {
return (
<div>
<strong>{workforceData?.per_company.find((item) => item.company_id == companyId)?.company_name || ''}</strong>
<br />
{__('ui.data_source_from_monthly_report')}
<br />
{__('ui.report_month', {
month: workforceData?.per_company.find((item) => item.company_id == companyId)?.report_month || '',
})}
<br />
{__('ui.excluding_third_party_employees')}
<br />
{__('ui.click_to_view_org_chart')}
</div>
);
};
return (
<div>
<ProCard className={styles.orgChart} direction={isDesktopOrAbove ? 'row' : 'column'}>
<ProCard
colSpan={{
xl: 18,
lg: 24,
md: 24,
}}
>
<Spin spinning={initRequest.loading}>
<h2 className={styles.title}>{__('title.chart_title')}</h2>
<div className={styles.chartButtons}>
<div>
<Space>
<Button
icon={<BankOutlined />}
size="large"
onClick={() => {
handleButtonClick('group');
}}
>
{__('title.group_org_chart')}
</Button>
<Button
icon={<CompassOutlined />}
size="large"
onClick={() => {
handleButtonClick('center');
}}
>
{__('title.center_org_chart')}
</Button>
</Space>
</div>
<div className={styles.countryButtons}>
<Space>
<Button
className="china-region"
onMouseEnter={() => {
handleButtonHover('greater-china');
}}
onMouseLeave={() => {
clearHighlight();
}}
onClick={() => {
handleButtonClick('greater-china');
}}
size="large"
>
{__('title.greater_china_chart')}
</Button>
<Button
className="southeast-asia-region"
onMouseEnter={() => {
handleButtonHover('southeast-asia');
}}
onMouseLeave={() => {
clearHighlight();
}}
onClick={() => {
handleButtonClick('southeast-asia');
}}
size="large"
>
{__('title.southeast_asia_chart')}
</Button>
<Button
className="south-asia-region"
onMouseEnter={() => {
handleButtonHover('south-asia');
}}
onMouseLeave={() => {
clearHighlight();
}}
onClick={() => {
handleButtonClick('south-asia');
}}
size="large"
>
{__('title.south_asia_chart')}
</Button>
<Button
className="america-region"
onMouseEnter={() => {
handleButtonHover('americas');
}}
onMouseLeave={() => {
clearHighlight();
}}
onClick={() => {
handleButtonClick('americas');
}}
size="large"
>
{__('title.americas_chart')}
</Button>
<Button
className="europe-region"
onMouseEnter={() => {
handleButtonHover('europe');
}}
onMouseLeave={() => {
clearHighlight();
}}
onClick={() => {
handleButtonClick('europe');
}}
size="large"
>
{__('title.europe_chart')}
</Button>
</Space>
</div>
</div>
{/* 世界地图百分比 began */}
<Row>
{/* 添加 style 标签定义动画 */}
<style>
{`
@keyframes blink {
0% { opacity: 0.2; }
50% { opacity: 1; }
100% { opacity: 0.2; }
}
`}
</style>
<Col span={24}>
<div className={styles.mapContainer}>
<img
src="https://a.com/world1.png"
alt="World Map"
className={styles.worldMap}
/>
{points.map((point, index) => (
<div
key={index}
className={styles.annotation}
style={{
top: `${point.y}%`,
left: `${point.x}%`,
}}
onMouseEnter={() => setHoveredPoint(point)}
onMouseLeave={() => setHoveredPoint(null)}
>
<div className={styles.text}>
<NumberRoller value={point.text} duration={1500} />
</div>
{hoveredPoint?.id === point.id && (
<div className={styles.tooltip} style={{ display: 'none' }}>
{point.details.map((detail, i) => (
<div key={i} className={styles.tooltipItem}>
<span>{detail.name}</span>
<span>{detail.value}</span>
</div>
))}
</div>
)}
</div>
))}
{false &&
pointsText.map((point, index) => (
<div
key={index}
className={styles.annotation}
style={{
top: `${point.y}%`,
left: `${point.x}%`,
}}
onMouseEnter={() => setHoveredPoint(point)}
onMouseLeave={() => setHoveredPoint(null)}
>
<div className={styles.text2}>{point.text}</div>
</div>
))}
</div>
</Col>
</Row>
{/* 居中显示 */}
<Row justify="center">
<Button icon={<GlobalOutlined />} size="middle">
{__('title.Dynamically')}
</Button>
</Row>
{/* 世界地图百分比 end */}
<ComposableMap width={1200} height={600} style={{ display: 'none' }}>
<Geographies geography={countryGeoJSON}>
{({ geographies }) =>
geographies.map((geo) => {
const isHighlighted = highlightCountries.includes(geo.properties.iso_a3);
return <Geography key={geo.rsmKey} geography={geo} fill={isHighlighted ? '#E4007F' : '#D6D6DA'} />;
})
}
</Geographies>
</ComposableMap>
</Spin>
</ProCard>
<ProCard
colSpan={{
xl: 6,
lg: 24,
md: 24,
}}
loading={initRequest.loading}
>
<ProCard split="horizontal">
<StatisticCard
statistic={{
title: __('title.total_workforce_count'),
value: workforceData?.include_count,
}}
/>
<StatisticCard
className={`${styles.workforceCompanyCard} ${styles.workforceExcludeTitle}`}
statistic={{
title: __('title.total_workforce_count_exclude'),
value: workforceData?.exclude_count,
}}
/>
<ProCard className={styles.companiesCard}>
<p>{__('title.greater_china_chart')}</p>
<Popover content={getHoverContent(1)}>
<Tag
color="#e57373"
onClick={() => {
openChart(8);
}}
>
{__('title.shanghai')} {getWorkforceCountForCompany(1)}
</Tag>
</Popover>
<Popover content={getHoverContent(2)}>
<Tag
color="#e57373"
onClick={() => {
openChart(9);
}}
>
{__('title.zhejiang')} {getWorkforceCountForCompany(2)}
</Tag>
</Popover>
<Popover content={getHoverContent(3)}>
<Tag
color="#e57373"
onClick={() => {
openChart(10);
}}
>
{__('title.shenzhen')} {getWorkforceCountForCompany(3)}
</Tag>
</Popover>
<Popover content={getHoverContent(4)}>
<Tag
color="#e57373"
onClick={() => {
openChart(11);
}}
>
{__('title.hongkong')} {getWorkforceCountForCompany(4)}
</Tag>
</Popover>
<Popover content={getHoverContent(19)}>
<Tag
color="#e57373"
onClick={() => {
openChart(25);
}}
>
{__('title.zhejiang_jiasheng')} {getWorkforceCountForCompany(19)}
</Tag>
</Popover>
<Popover content={getHoverContent(20)}>
<Tag
color="#e57373"
onClick={() => {
openChart(26);
}}
>
{__('title.evermaxglobal')} {getWorkforceCountForCompany(20)}
</Tag>
</Popover>
<p>{__('title.southeast_asia_chart')}</p>
<Popover content={getHoverContent(5)}>
<Tag
color="#ba68c8"
onClick={() => {
openChart(12);
}}
>
{__('title.vietnam')} {getWorkforceCountForCompany(5)}
</Tag>
</Popover>
<Popover content={getHoverContent(6)}>
<Tag
color="#ba68c8"
onClick={() => {
openChart(13);
}}
>
{__('title.cambodia')} {getWorkforceCountForCompany(6)}
</Tag>
</Popover>
<Popover content={getHoverContent(7)}>
<Tag
color="#ba68c8"
onClick={() => {
openChart(14);
}}
>
{__('title.thailand')} {getWorkforceCountForCompany(7)}
</Tag>
</Popover>
<Popover content={getHoverContent(8)}>
<Tag
color="#ba68c8"
onClick={() => {
openChart(15);
}}
>
{__('title.indonesia_sales')} {getWorkforceCountForCompany(8)}
</Tag>
</Popover>
<Popover content={getHoverContent(9)}>
<Tag
color="#ba68c8"
onClick={() => {
openChart(15);
}}
>
{__('title.indonesia_production')} {getWorkforceCountForCompany(9)}
</Tag>
</Popover>
<p>{__('title.south_asia_chart')}</p>
<Popover content={getHoverContent(10)}>
<Tag
color="#81c784"
onClick={() => {
openChart(16);
}}
>
{__('title.india')} {getWorkforceCountForCompany(10)}
</Tag>
</Popover>
<Popover content={getHoverContent(11)}>
<Tag
color="#81c784"
onClick={() => {
openChart(17);
}}
>
{__('title.bangladesh')} {getWorkforceCountForCompany(11)}
</Tag>
</Popover>
<p>{__('title.americas_chart')}</p>
<Popover content={getHoverContent(17)}>
<Tag
color="#4fc3f7"
onClick={() => {
openChart(23);
}}
>
{__('title.united_states')} {getWorkforceCountForCompany(17)}
</Tag>
</Popover>
<Popover content={getHoverContent(18)}>
<Tag
color="#4fc3f7"
onClick={() => {
openChart(24);
}}
>
{__('title.guatemala')} {getWorkforceCountForCompany(18)}
</Tag>
</Popover>
<p>{__('title.europe_chart')}</p>
<Popover content={getHoverContent(13)}>
<Tag
color="#ffd54f"
onClick={() => {
openChart(19);
}}
>
{__('title.türkiye')} {getWorkforceCountForCompany(13)}
</Tag>
</Popover>
<Popover content={getHoverContent(14)}>
<Tag
color="#ffd54f"
onClick={() => {
openChart(20);
}}
>
{__('title.spain')} {getWorkforceCountForCompany(14)}
</Tag>
</Popover>
<Popover content={getHoverContent(12)}>
<Tag
color="#ffd54f"
onClick={() => {
openChart(18);
}}
>
{__('title.united_kingdom')} {getWorkforceCountForCompany(12)}
</Tag>
</Popover>
<Popover content={getHoverContent(15)}>
<Tag
color="#ffd54f"
onClick={() => {
openChart(21);
}}
>
{__('title.germany')} {getWorkforceCountForCompany(15)}
</Tag>
</Popover>
<Popover content={getHoverContent(16)}>
<Tag
color="#ffd54f"
onClick={() => {
openChart(22);
}}
>
{__('title.dubai')} {getWorkforceCountForCompany(16)}
</Tag>
</Popover>
</ProCard>
</ProCard>
</ProCard>
</ProCard>
<Modal
open={previewModoalOpen}
width={'90%'}
centered
footer={false}
maskClosable
destroyOnClose
onCancel={() => {
setPreviewModoalOpen(false);
}}
>
<OrgCharts id={chartId} config={{}} isEditable={false} entityData={data} />
</Modal>
</div>
);
};
export default Index;
结束