308 lines
10 KiB
Vue
308 lines
10 KiB
Vue
<template>
|
|
<div class="relative bg-white py-4 dark:bg-gray-950">
|
|
<div class="text-center">
|
|
<h2 class="text-title-sm font-semibold text-gray-950 dark:text-white lg:text-title-md">全国IP分布热点图</h2>
|
|
<p v-if="currentId !== initialMapId" class="mt-3 text-sm text-sky-600 dark:text-sky-300">{{ currentName }}</p>
|
|
</div>
|
|
|
|
<div class="mt-10 grid items-center gap-8 lg:grid-cols-[290px_1fr_220px]">
|
|
<div class="lg:pt-4">
|
|
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200">节点遍布全国150+城市</h3>
|
|
<ul class="mt-6 grid gap-3 text-base leading-7 text-gray-600 dark:text-gray-300">
|
|
<li v-for="item in summaryItems" :key="item" class="flex gap-2">
|
|
<span class="mt-3 h-1.5 w-1.5 shrink-0 rounded-full bg-gray-500 dark:bg-gray-400"></span>
|
|
<span>{{ item }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="relative min-h-[420px]">
|
|
<svg class="h-full min-h-[420px] w-full drop-shadow-[0_20px_36px_rgba(14,165,233,0.16)]" :viewBox="`0 0 ${svgWidth} ${svgHeight}`" role="img" aria-label="IP 节点热力地图">
|
|
<path
|
|
v-for="region in regions"
|
|
:key="region.key"
|
|
:d="region.path"
|
|
:fill="region.fill"
|
|
class="cursor-pointer stroke-white stroke-[1.3] transition hover:opacity-80 dark:stroke-gray-950"
|
|
@click="drillDown(region)"
|
|
/>
|
|
<text
|
|
v-for="region in labelRegions"
|
|
:key="`${region.key}-label`"
|
|
:x="region.labelX"
|
|
:y="region.labelY"
|
|
class="pointer-events-none select-none fill-gray-700 text-[11px] font-semibold dark:fill-gray-200"
|
|
text-anchor="middle"
|
|
>
|
|
{{ region.name }}
|
|
</text>
|
|
</svg>
|
|
|
|
<div v-if="loading" class="absolute inset-0 grid place-items-center bg-white/70 text-sm font-semibold text-sky-700 backdrop-blur dark:bg-gray-950/70 dark:text-sky-200">
|
|
正在加载地图...
|
|
</div>
|
|
<div v-if="error" class="absolute inset-x-4 bottom-4 rounded-xl bg-error-50 px-4 py-3 text-sm text-error-600 dark:bg-error-500/15 dark:text-error-300">
|
|
{{ error }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-6 flex flex-wrap items-center justify-center gap-5 text-sm text-gray-700 dark:text-gray-300">
|
|
<span class="inline-flex items-center gap-2">
|
|
<i class="h-4 w-6 rounded bg-[#1f7df2]"></i>
|
|
已开通
|
|
</span>
|
|
<span class="inline-flex items-center gap-2">
|
|
<i class="h-4 w-6 rounded bg-[#d8edff]"></i>
|
|
暂未开通
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex h-full flex-col justify-end gap-4 pb-12">
|
|
<strong class="text-xl font-semibold text-gray-600 dark:text-gray-300">全国IP分布情况</strong>
|
|
<span class="text-sm text-gray-400 dark:text-gray-500">点击地图区域可进入下一级</span>
|
|
<button
|
|
v-if="currentId !== initialMapId"
|
|
type="button"
|
|
class="h-10 w-fit rounded-lg border border-sky-200 bg-white px-4 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 dark:border-sky-500/20 dark:bg-white/[0.06] dark:text-sky-200"
|
|
@click="resetMap"
|
|
>
|
|
返回全国
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
type Position = [number, number]
|
|
type Ring = Position[]
|
|
type PolygonCoordinates = Ring[]
|
|
type MultiPolygonCoordinates = PolygonCoordinates[]
|
|
|
|
type GeoFeature = {
|
|
type: 'Feature'
|
|
properties?: {
|
|
name?: string
|
|
adcode?: number | string
|
|
center?: Position
|
|
centroid?: Position
|
|
childrenNum?: number
|
|
}
|
|
geometry?: {
|
|
type: 'Polygon' | 'MultiPolygon'
|
|
coordinates: PolygonCoordinates | MultiPolygonCoordinates
|
|
}
|
|
}
|
|
|
|
type GeoJson = {
|
|
type: 'FeatureCollection'
|
|
features: GeoFeature[]
|
|
}
|
|
|
|
type RegionPath = {
|
|
key: string
|
|
name: string
|
|
adcode: string
|
|
path: string
|
|
fill: string
|
|
labelX: number
|
|
labelY: number
|
|
}
|
|
|
|
const initialMapId = '100000_full'
|
|
const svgWidth = 760
|
|
const svgHeight = 500
|
|
const padding = 24
|
|
const openedColor = '#1f7df2'
|
|
const unopenedColor = '#d8edff'
|
|
const openedProvinceCodes = new Set([
|
|
'110000',
|
|
'120000',
|
|
'130000',
|
|
'140000',
|
|
'210000',
|
|
'220000',
|
|
'230000',
|
|
'310000',
|
|
'320000',
|
|
'330000',
|
|
'340000',
|
|
'350000',
|
|
'360000',
|
|
'370000',
|
|
'410000',
|
|
'420000',
|
|
'430000',
|
|
'440000',
|
|
'450000',
|
|
'460000',
|
|
'500000',
|
|
'510000',
|
|
'520000',
|
|
'530000',
|
|
])
|
|
const summaryItems = ['日更IP量超300万+', '可用率95%以上', '支持HTTP/HTTPS']
|
|
|
|
const currentId = ref(initialMapId)
|
|
const currentName = ref('全国 IP 节点热力图')
|
|
const geoJson = ref<GeoJson | null>(null)
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
|
|
const regions = computed<RegionPath[]>(() => {
|
|
if (!geoJson.value?.features?.length) return []
|
|
return buildRegionPaths(geoJson.value.features)
|
|
})
|
|
|
|
const labelRegions = computed(() => {
|
|
if (currentId.value === initialMapId) return []
|
|
return regions.value.length <= 45 ? regions.value : regions.value.slice(0, 45)
|
|
})
|
|
|
|
onMounted(() => {
|
|
void loadMap(initialMapId, '全国 IP 节点热力图', false)
|
|
})
|
|
|
|
async function drillDown(region: RegionPath) {
|
|
if (!region.adcode) {
|
|
await resetMap()
|
|
return
|
|
}
|
|
await loadMap(`${region.adcode}_full`, `${region.name} IP 节点热力图`, true)
|
|
}
|
|
|
|
async function resetMap() {
|
|
await loadMap(initialMapId, '全国 IP 节点热力图', false)
|
|
}
|
|
|
|
async function loadMap(id: string, name: string, fallbackToRoot: boolean) {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const response = await fetch(mapUrl(id))
|
|
if (!response.ok) throw new Error(`地图数据请求失败:${response.status}`)
|
|
const payload = await response.json()
|
|
const data = normalizeGeoJson(payload)
|
|
if (!data?.features?.length) throw new Error('暂无下级地图数据')
|
|
geoJson.value = data
|
|
currentId.value = id
|
|
currentName.value = name
|
|
} catch (err) {
|
|
if (fallbackToRoot) {
|
|
await loadMap(initialMapId, '全国 IP 节点热力图', false)
|
|
error.value = '当前区域暂无下级地图,已返回全国。'
|
|
return
|
|
}
|
|
error.value = err instanceof Error ? err.message : '地图数据加载失败'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function mapUrl(id: string) {
|
|
const normalizedId = id.endsWith('_full') ? id : `${id}_full`
|
|
const base = window.location.hostname.endsWith('qiyunip.com') ? '' : 'https://www.qiyunip.com'
|
|
return `${base}/api/map/getjson?id=${encodeURIComponent(normalizedId)}`
|
|
}
|
|
|
|
function normalizeGeoJson(payload: unknown): GeoJson | null {
|
|
const value = payload as { type?: string; features?: GeoFeature[]; data?: unknown }
|
|
if (value?.type === 'FeatureCollection' && Array.isArray(value.features)) return value as GeoJson
|
|
const data = value?.data as { type?: string; features?: GeoFeature[] } | undefined
|
|
if (data?.type === 'FeatureCollection' && Array.isArray(data.features)) return data as GeoJson
|
|
return null
|
|
}
|
|
|
|
function buildRegionPaths(features: GeoFeature[]) {
|
|
const bbox = getFeatureBounds(features)
|
|
const scale = Math.min(
|
|
(svgWidth - padding * 2) / Math.max(bbox.maxX - bbox.minX, 1),
|
|
(svgHeight - padding * 2) / Math.max(bbox.maxY - bbox.minY, 1),
|
|
)
|
|
const offsetX = (svgWidth - (bbox.maxX - bbox.minX) * scale) / 2
|
|
const offsetY = (svgHeight - (bbox.maxY - bbox.minY) * scale) / 2
|
|
const project = ([lng, lat]: Position) => ({
|
|
x: offsetX + (lng - bbox.minX) * scale,
|
|
y: offsetY + (bbox.maxY - lat) * scale,
|
|
})
|
|
|
|
return features
|
|
.map((feature, index) => {
|
|
const name = feature.properties?.name || '未知区域'
|
|
const adcode = feature.properties?.adcode ? String(feature.properties.adcode) : ''
|
|
const center = feature.properties?.centroid || feature.properties?.center || getFeatureCenter(feature)
|
|
const label = project(center)
|
|
return {
|
|
key: `${adcode || name}-${index}`,
|
|
name,
|
|
adcode,
|
|
path: geometryToPath(feature, project),
|
|
fill: isOpenedRegion(adcode, index) ? openedColor : unopenedColor,
|
|
labelX: label.x,
|
|
labelY: label.y,
|
|
}
|
|
})
|
|
.filter((item) => item.path)
|
|
}
|
|
|
|
function geometryToPath(feature: GeoFeature, project: (position: Position) => { x: number; y: number }) {
|
|
const geometry = feature.geometry
|
|
if (!geometry) return ''
|
|
if (geometry.type === 'Polygon') return polygonToPath(geometry.coordinates as PolygonCoordinates, project)
|
|
if (geometry.type === 'MultiPolygon') {
|
|
return (geometry.coordinates as MultiPolygonCoordinates).map((polygon) => polygonToPath(polygon, project)).join(' ')
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function polygonToPath(polygon: PolygonCoordinates, project: (position: Position) => { x: number; y: number }) {
|
|
return polygon
|
|
.map((ring) =>
|
|
ring
|
|
.map((position, index) => {
|
|
const point = project(position)
|
|
return `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)},${point.y.toFixed(2)}`
|
|
})
|
|
.join(' ') + ' Z',
|
|
)
|
|
.join(' ')
|
|
}
|
|
|
|
function getFeatureBounds(features: GeoFeature[]) {
|
|
const positions = features.flatMap((feature) => getFeaturePositions(feature))
|
|
return positions.reduce(
|
|
(bbox, [lng, lat]) => ({
|
|
minX: Math.min(bbox.minX, lng),
|
|
minY: Math.min(bbox.minY, lat),
|
|
maxX: Math.max(bbox.maxX, lng),
|
|
maxY: Math.max(bbox.maxY, lat),
|
|
}),
|
|
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
|
)
|
|
}
|
|
|
|
function getFeaturePositions(feature: GeoFeature) {
|
|
const geometry = feature.geometry
|
|
if (!geometry) return []
|
|
if (geometry.type === 'Polygon') return (geometry.coordinates as PolygonCoordinates).flat()
|
|
return (geometry.coordinates as MultiPolygonCoordinates).flat(2)
|
|
}
|
|
|
|
function getFeatureCenter(feature: GeoFeature): Position {
|
|
const positions = getFeaturePositions(feature)
|
|
if (!positions.length) return [105, 35]
|
|
const sum = positions.reduce((acc, [lng, lat]) => ({ lng: acc.lng + lng, lat: acc.lat + lat }), { lng: 0, lat: 0 })
|
|
return [sum.lng / positions.length, sum.lat / positions.length]
|
|
}
|
|
|
|
function isOpenedRegion(adcode: string, index: number) {
|
|
if (!adcode) return false
|
|
if (currentId.value === initialMapId) return openedProvinceCodes.has(adcode)
|
|
return index % 3 !== 0
|
|
}
|
|
</script>
|