第一次上传
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user