You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

3117 lines
107 KiB

<template>
<div ref="containerRef" class="cesium-map">
<div ref="viewerRef" class="cesium-map__viewer"></div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
import polygonClipping from "polygon-clipping"
import { HONGYUAN_BOUNDS } from "@/config/layers"
import { CHINA_GEOJSON_PATH, HONGYUAN_COUNTY_OUTLINE_PATH, getPublicMapResourcePath } from "@/config/mapResources"
import { hongyuanTownshipLabelPoints, hongyuanTownshipsGeoJson } from "@/config/townshipBoundaries"
const NATIONAL_BACKDROP_BOUNDS = {
west: 100.18,
south: 30.96,
east: 105.02,
north: 34.46,
}
const HONGYUAN_HIGH_RES_MAX_LEVEL = 17
const COUNTY_VIEW_HEADING = -18
const COUNTY_VIEW_PITCH = -50
const COUNTY_VIEW_CENTER_LAT_OFFSET = 0.045
const DETAIL_VIEW_HEADING = -18
const DETAIL_VIEW_PITCH = -58
const COUNTY_MASK_PADDING_LNG = 4.2
const COUNTY_MASK_PADDING_LAT = 3.2
const COUNTY_WALL_MIN_HEIGHT = 500
const COUNTY_WALL_MAX_HEIGHT = 7600
const COUNTY_STAGE_HEIGHT = 900
const COUNTY_BOUNDARY_RENDER_HEIGHT = 4700
const TOWNSHIP_BOUNDARY_RENDER_HEIGHT = 4620
let hongyuanCountyClipMultiPolygon = []
let hongyuanCountyBoundaryPromise = null
const props = defineProps({
active: {
type: Boolean,
default: true,
},
activeLayer: {
type: Object,
default: null,
},
mapRanking: {
type: Object,
default: null,
},
overlayLayer: {
type: Object,
default: null,
},
overlayLayers: {
type: Array,
default: () => [],
},
tileUrlTemplate: {
type: String,
default: "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png",
},
referenceTileUrlTemplate: {
type: String,
default: "https://map.shuxitech.com/vt/lyrs=s&hl=zh-CN&x={x}&y={y}&z={z}",
},
terrainUrl: {
type: String,
default: "/terrain",
},
center: {
type: Array,
default: () => [102.600005, 32.585593],
},
initialZoom: {
type: Number,
default: 9,
},
minimumZoomDistance: {
type: Number,
default: 350,
},
countyViewTargetOffset: {
type: Object,
default: null,
},
autoApplyLayerMapView: {
type: Boolean,
default: true,
},
maxZoom: {
type: Number,
default: 21,
},
adminBoundaryLineScale: {
type: Number,
default: 1,
},
showNationalBackdrop: {
type: Boolean,
default: true,
},
enableImageryFeaturePick: {
type: Boolean,
default: true,
},
enableTownPolygonPick: {
type: Boolean,
default: true,
},
hideTownLabelStats: {
type: Boolean,
default: false,
},
})
const emit = defineEmits([
"ready",
"scene-mounted",
"feature-loading",
"feature-info",
"layer-status",
"tile-status",
"map-click",
"draw-boundary",
])
const containerRef = ref(null)
const viewerRef = ref(null)
const canvasMap = shallowRef(null)
const activeLayerStack = computed(() => {
if (props.activeLayer) {
return [props.activeLayer, ...(Array.isArray(props.activeLayer.overlayLayers) ? props.activeLayer.overlayLayers : [])].filter(Boolean)
}
if (props.overlayLayers.length) return props.overlayLayers.filter(Boolean)
return props.overlayLayer ? [props.overlayLayer] : []
})
let viewer = null
let clickHandler = null
let resizeObserver = null
let cameraMoveEndRemove = null
let terrainFallbackApplied = false
let layerRevision = 0
let selectedEntity = null
let drawingEnabled = false
let drawingPoints = []
let baseImageryLayer = null
let nationalBackdropImageryLayer = null
let topicImageryLayers = []
let boundaryEntities = []
let topicEntities = []
let rankingEntities = []
let focusEntities = []
let selectedOverlayEntities = []
let selectedOverlayDataSource = null
let drawingEntities = []
let analysisEntities = []
let countyFrameEntities = []
let countyStageEntities = []
const townBadgeImageCache = new Map()
onMounted(() => {
emit("scene-mounted")
if (props.active) initializeCesium()
})
onBeforeUnmount(() => destroyMap())
watch(
activeLayerStack,
() => refreshTopicLayers(),
{ deep: true },
)
watch(
() => props.mapRanking,
() => refreshMapRanking(),
{ deep: true },
)
watch(
() => props.hideTownLabelStats,
() => refreshMapRanking(),
)
watch(
() => props.active,
async (active) => {
if (!active) {
destroyMap()
return
}
await nextTick()
initializeCesium()
},
)
function initializeCesium() {
if (viewer || !viewerRef.value) return Boolean(viewer)
const Cesium = window.Cesium
if (!Cesium) {
emit("layer-status", { type: "error", text: "Cesium 运行资源加载失败" })
return false
}
terrainFallbackApplied = false
const terrainProvider = createTerrainProvider(Cesium)
const normalizedTileUrlTemplate = normalizeTileUrlTemplate(props.tileUrlTemplate)
const normalizedReferenceTileUrlTemplate = normalizeTileUrlTemplate(props.referenceTileUrlTemplate)
const hasHongyuanHighResolutionTile = isHongyuanHighResolutionTile(normalizedTileUrlTemplate)
const referenceProvider =
normalizedReferenceTileUrlTemplate && normalizedReferenceTileUrlTemplate !== normalizedTileUrlTemplate
? createImageryProvider(Cesium, normalizedReferenceTileUrlTemplate, {
maximumLevel: Math.min(props.maxZoom, 19),
})
: null
const baseProvider = normalizedTileUrlTemplate
? createImageryProvider(Cesium, normalizedTileUrlTemplate, {
maximumLevel: hasHongyuanHighResolutionTile
? Math.min(props.maxZoom, HONGYUAN_HIGH_RES_MAX_LEVEL)
: props.maxZoom,
rectangle: hasHongyuanHighResolutionTile ? getHongyuanImageryRectangle(Cesium) : null,
})
: null
viewer = new Cesium.Viewer(viewerRef.value, {
animation: false,
baseLayerPicker: false,
fullscreenButton: false,
geocoder: false,
homeButton: false,
infoBox: false,
navigationHelpButton: false,
sceneModePicker: false,
selectionIndicator: false,
timeline: false,
scene3DOnly: true,
imageryProvider: false,
terrainProvider,
useBrowserRecommendedResolution: false,
requestRenderMode: true,
maximumRenderTimeChange: Infinity,
contextOptions: {
webgl: {
alpha: true,
antialias: true,
},
},
})
if (referenceProvider) {
const referenceLayer = viewer.imageryLayers.addImageryProvider(referenceProvider)
referenceLayer.alpha = 1
referenceLayer.brightness = 0.68
referenceLayer.contrast = 1.08
referenceLayer.saturation = 0.48
referenceLayer.gamma = 0.9
}
if (baseProvider) {
baseImageryLayer = viewer.imageryLayers.addImageryProvider(baseProvider)
baseImageryLayer.alpha = hasHongyuanHighResolutionTile ? 0.88 : 1
baseImageryLayer.brightness = hasHongyuanHighResolutionTile ? 0.96 : 0.9
baseImageryLayer.contrast = hasHongyuanHighResolutionTile ? 1.12 : 1.1
baseImageryLayer.saturation = hasHongyuanHighResolutionTile ? 1.08 : 1.05
baseImageryLayer.gamma = hasHongyuanHighResolutionTile ? 0.98 : 0.96
}
viewer.scene.globe.terrainExaggeration = 1.5
viewer.scene.globe.depthTestAgainstTerrain = false
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#061821")
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#020b12")
viewer.scene.skyAtmosphere.show = false
if (viewer.scene.skyBox) viewer.scene.skyBox.show = false
viewer.scene.fog.enabled = true
viewer.scene.fog.density = 0.00018
viewer.resolutionScale = Math.min(Math.max(window.devicePixelRatio || 1, 1), 2)
viewer.scene.fxaa = false
viewer.scene.postProcessStages.fxaa.enabled = false
viewer.scene.screenSpaceCameraController.enableCollisionDetection = true
viewer.scene.screenSpaceCameraController.minimumZoomDistance = Math.max(
1,
Number(props.minimumZoomDistance) || 1,
)
viewer.scene.screenSpaceCameraController.maximumZoomDistance = 560000
if (viewer.cesiumWidget?.creditContainer) viewer.cesiumWidget.creditContainer.style.display = "none"
if (viewer._cesiumWidget?._creditContainer) viewer._cesiumWidget._creditContainer.style.display = "none"
bindTerrainFallback(terrainProvider, Cesium)
loadCountyBoundary()
.then(() => {
if (!viewer || viewer.isDestroyed?.()) return
addCountyFrame()
updateBoundaryVisualScale()
})
.catch((error) => {
console.warn("[cesium-map] county outline load failed", error)
emit("layer-status", { type: "error", text: "红原县边界加载失败" })
})
addTownshipBoundaries()
bindMapClick()
connectResizeObserver()
connectBoundaryVisualScale()
setCountyView(false)
updateBoundaryVisualScale()
refreshTopicLayers()
refreshMapRanking()
const facade = {
viewer,
clearSelectedFeature,
focusProject,
focusPasture,
focusTownByName: focusTown,
focusYakIndustryPoint,
focusVectorFeature,
getVectorFeatureAnchorScreen,
getVectorFeaturesAnchorScreen,
getVectorFeatureScreen,
getYakIndustryPointScreen,
destroy: destroyMap,
}
canvasMap.value = facade
baseProvider?.errorEvent?.addEventListener?.(() => {
emit("tile-status", { type: "warning", text: "牧场影像底图部分瓦片加载失败" })
})
emit("tile-status", { type: "success", text: "牧场影像底图已加载" })
window.setTimeout(() => {
if (viewer && !viewer.isDestroyed?.()) emit("ready")
}, 0)
nextTick(refreshView)
return true
}
function createTerrainProvider(Cesium) {
if (!props.terrainUrl) return new Cesium.EllipsoidTerrainProvider()
try {
return new Cesium.CesiumTerrainProvider({
url: props.terrainUrl,
requestVertexNormals: true,
requestWaterMask: false,
})
} catch (error) {
terrainFallbackApplied = true
return new Cesium.EllipsoidTerrainProvider()
}
}
function createImageryProvider(Cesium, url, options = {}) {
if (!url) return null
return new Cesium.UrlTemplateImageryProvider({
url,
minimumLevel: options.minimumLevel || 3,
maximumLevel: options.maximumLevel || props.maxZoom,
rectangle: options.rectangle || undefined,
tilingScheme: new Cesium.WebMercatorTilingScheme(),
credit: "",
})
}
function normalizeTileUrlTemplate(url) {
return String(url || "").trim()
}
function isHongyuanHighResolutionTile(url) {
return /hy-result|qkl-map/i.test(String(url || ""))
}
function getHongyuanImageryRectangle(Cesium) {
const paddingLng = 0.035
const paddingLat = 0.035
return Cesium.Rectangle.fromDegrees(
HONGYUAN_BOUNDS.west - paddingLng,
HONGYUAN_BOUNDS.south - paddingLat,
HONGYUAN_BOUNDS.east + paddingLng,
HONGYUAN_BOUNDS.north + paddingLat,
)
}
async function addNationalBackdrop(Cesium) {
const targetViewer = viewer
try {
const response = await fetch(getPublicMapResourcePath(CHINA_GEOJSON_PATH), { credentials: "same-origin" })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const canvas = createNationalBackdropCanvas(await response.json())
if (!canvas || viewer !== targetViewer || targetViewer?.isDestroyed?.()) return
const provider = new Cesium.SingleTileImageryProvider({
url: canvas.toDataURL("image/png"),
rectangle: Cesium.Rectangle.fromDegrees(
NATIONAL_BACKDROP_BOUNDS.west,
NATIONAL_BACKDROP_BOUNDS.south,
NATIONAL_BACKDROP_BOUNDS.east,
NATIONAL_BACKDROP_BOUNDS.north,
),
credit: "",
})
nationalBackdropImageryLayer = targetViewer.imageryLayers.addImageryProvider(provider, 0)
nationalBackdropImageryLayer.alpha = 0.68
nationalBackdropImageryLayer.brightness = 0.84
nationalBackdropImageryLayer.contrast = 1.16
targetViewer.scene.requestRender()
} catch (error) {
console.warn("[cesium-map] national backdrop failed", error)
}
}
function createNationalBackdropCanvas(data) {
const features = data?.type === "FeatureCollection" ? data.features : []
const points = []
features.forEach((feature) => collectCoordinates(feature?.geometry?.coordinates, points))
if (!features.length || !points.length) return null
const canvas = document.createElement("canvas")
canvas.width = 2400
canvas.height = 1600
const context = canvas.getContext("2d")
if (!context) return null
const sourceBounds = points.reduce((bounds, point) => ({
west: Math.min(bounds.west, point[0]),
south: Math.min(bounds.south, point[1]),
east: Math.max(bounds.east, point[0]),
north: Math.max(bounds.north, point[1]),
}), { west: Infinity, south: Infinity, east: -Infinity, north: -Infinity })
const padding = 92
const sourceWidth = Math.max(0.0001, sourceBounds.east - sourceBounds.west)
const sourceHeight = Math.max(0.0001, sourceBounds.north - sourceBounds.south)
const scale = Math.min(
(canvas.width - padding * 2) / sourceWidth,
(canvas.height - padding * 2) / sourceHeight,
)
const offsetX = (canvas.width - sourceWidth * scale) / 2
const offsetY = (canvas.height - sourceHeight * scale) / 2
const project = (coordinate) => [
offsetX + (Number(coordinate[0]) - sourceBounds.west) * scale,
canvas.height - offsetY - (Number(coordinate[1]) - sourceBounds.south) * scale,
]
drawBackdropGrid(context, canvas)
drawBackdropHalo(context, canvas)
const drawPath = () => {
context.beginPath()
features.forEach((feature) => {
forEachPolygon(feature?.geometry, (rings) => {
rings.forEach((ring) => {
ring.forEach((coordinate, index) => {
const point = project(coordinate)
if (index === 0) context.moveTo(point[0], point[1])
else context.lineTo(point[0], point[1])
})
context.closePath()
})
})
})
}
const fill = context.createLinearGradient(0, 0, canvas.width, canvas.height)
fill.addColorStop(0, "rgba(16, 47, 76, 0.14)")
fill.addColorStop(0.48, "rgba(8, 38, 65, 0.1)")
fill.addColorStop(1, "rgba(3, 22, 42, 0.06)")
drawPath()
context.fillStyle = fill
context.fill("evenodd")
drawPath()
context.save()
context.strokeStyle = "rgba(37, 139, 207, 0.14)"
context.lineWidth = 5.6
context.shadowColor = "rgba(38, 170, 255, 0.2)"
context.shadowBlur = 14
context.stroke()
context.restore()
drawPath()
context.strokeStyle = "rgba(89, 168, 221, 0.32)"
context.lineWidth = 1
context.stroke()
drawProvinceInnerLines(context, features, project)
drawBackdropScanArcs(context, canvas)
return canvas
}
function drawBackdropGrid(context, canvas) {
const width = canvas.width
const height = canvas.height
context.save()
context.globalAlpha = 0.72
context.fillStyle = "rgba(0, 0, 0, 0)"
context.clearRect(0, 0, width, height)
const verticalGradient = context.createLinearGradient(0, 0, 0, height)
verticalGradient.addColorStop(0, "rgba(25, 84, 126, 0.02)")
verticalGradient.addColorStop(0.52, "rgba(20, 93, 145, 0.08)")
verticalGradient.addColorStop(1, "rgba(9, 45, 77, 0.03)")
context.fillStyle = verticalGradient
context.fillRect(0, 0, width, height)
context.strokeStyle = "rgba(47, 128, 179, 0.18)"
context.lineWidth = 1
for (let x = -width * 0.18; x <= width * 1.16; x += 88) {
context.beginPath()
context.moveTo(x, height)
context.lineTo(x + width * 0.34, 0)
context.stroke()
}
for (let y = 120; y <= height - 70; y += 86) {
context.beginPath()
context.moveTo(0, y)
context.lineTo(width, y - 110)
context.stroke()
}
context.fillStyle = "rgba(49, 157, 213, 0.22)"
for (let x = 80; x < width; x += 76) {
for (let y = 84; y < height; y += 76) {
const fade = 1 - Math.min(0.78, Math.hypot(x - width * 0.5, y - height * 0.54) / (width * 0.62))
if (fade <= 0.1) continue
context.globalAlpha = fade * 0.5
context.fillRect(x, y, 3, 3)
}
}
context.restore()
}
function drawBackdropHalo(context, canvas) {
const width = canvas.width
const height = canvas.height
const centerX = width * 0.5
const centerY = height * 0.52
context.save()
context.globalCompositeOperation = "lighter"
const glow = context.createRadialGradient(centerX, centerY, 80, centerX, centerY, width * 0.58)
glow.addColorStop(0, "rgba(38, 198, 255, 0.16)")
glow.addColorStop(0.36, "rgba(30, 132, 208, 0.08)")
glow.addColorStop(1, "rgba(10, 38, 68, 0)")
context.fillStyle = glow
context.fillRect(0, 0, width, height)
context.restore()
}
function drawBackdropScanArcs(context, canvas) {
const width = canvas.width
const height = canvas.height
const centerX = width * 0.5
const centerY = height * 0.52
context.save()
context.globalCompositeOperation = "lighter"
context.lineCap = "round"
;[
{ radius: 330, start: -0.32, end: 0.78, alpha: 0.28 },
{ radius: 430, start: 2.58, end: 3.52, alpha: 0.2 },
{ radius: 540, start: 4.92, end: 5.76, alpha: 0.16 },
].forEach((arc) => {
context.beginPath()
context.strokeStyle = `rgba(45, 186, 255, ${arc.alpha})`
context.lineWidth = 8
context.arc(centerX, centerY, arc.radius, arc.start, arc.end)
context.stroke()
})
context.restore()
}
function drawProvinceInnerLines(context, features, project) {
context.save()
context.strokeStyle = "rgba(94, 169, 218, 0.18)"
context.lineWidth = 0.6
features.forEach((feature) => {
context.beginPath()
forEachPolygon(feature?.geometry, (rings) => {
rings.forEach((ring) => {
ring.forEach((coordinate, index) => {
const point = project(coordinate)
if (index === 0) context.moveTo(point[0], point[1])
else context.lineTo(point[0], point[1])
})
context.closePath()
})
})
context.stroke()
})
context.restore()
}
function forEachPolygon(geometry, callback) {
if (!geometry || typeof callback !== "function") return
if (geometry.type === "Polygon") {
callback(geometry.coordinates || [])
return
}
if (geometry.type === "MultiPolygon") {
;(geometry.coordinates || []).forEach(callback)
}
}
function createFeatureClipMultiPolygon(data, label = "feature") {
const featurePolygons = []
;(data?.features || []).forEach((feature) => {
const geometry = normalizeGeometry(feature?.geometry)
getGeometryPolygons(geometry).forEach((polygon) => {
const cleanPolygon = sanitizeClipPolygon(polygon)
if (cleanPolygon) featurePolygons.push(cleanPolygon)
})
})
if (!featurePolygons.length) return []
try {
return polygonClipping.union(...featurePolygons)
} catch (error) {
console.warn(`[cesium-map] ${label} union boundary failed`, error)
return featurePolygons.map((polygon) => polygon)
}
}
async function loadCountyBoundary() {
if (hongyuanCountyClipMultiPolygon.length) return hongyuanCountyClipMultiPolygon
if (!hongyuanCountyBoundaryPromise) {
hongyuanCountyBoundaryPromise = fetchJson(HONGYUAN_COUNTY_OUTLINE_PATH)
.then((data) => {
const polygons = createFeatureClipMultiPolygon(data, "county outline")
hongyuanCountyClipMultiPolygon = polygons
return polygons
})
.catch((error) => {
hongyuanCountyBoundaryPromise = null
throw error
})
}
return hongyuanCountyBoundaryPromise
}
function sanitizeClipPolygon(polygon) {
if (!Array.isArray(polygon)) return null
const rings = polygon
.map((ring) => closeClipRing((ring || []).map(normalizeLngLat).filter(Boolean)))
.filter((ring) => ring.length >= 4)
return rings.length ? rings : null
}
function closeClipRing(ring = []) {
if (!ring.length) return []
const first = ring[0]
const last = ring[ring.length - 1]
if (isSameLngLat(first, last)) return ring
return [...ring, first]
}
function bindTerrainFallback(provider, Cesium) {
const applyFallback = () => {
if (!viewer || terrainFallbackApplied) return
terrainFallbackApplied = true
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider()
viewer.scene.globe.terrainExaggeration = 1
viewer.scene.requestRender()
emit("layer-status", { type: "warning", text: "CXPT 地形服务暂不可用,已保留三维影像地图" })
}
provider?.errorEvent?.addEventListener?.(applyFallback)
provider?.readyPromise?.catch?.(applyFallback)
}
function loadMap() {
return initializeCesium()
}
function hasMap() {
return Boolean(viewer && !viewer.isDestroyed?.())
}
function play() {
resume()
}
function resume() {
if (!viewer) {
initializeCesium()
return
}
viewer.useDefaultRenderLoop = true
refreshView()
}
function pause() {
destroyMap()
}
function refreshView() {
if (!viewer || viewer.isDestroyed?.()) return
viewer.resize()
viewer.scene.requestRender()
}
function captureCameraView() {
if (!viewer?.camera || !window.Cesium) return null
const Cesium = window.Cesium
const camera = viewer.camera
return {
position: Cesium.Cartesian3.clone(camera.positionWC || camera.position),
direction: Cesium.Cartesian3.clone(camera.directionWC || camera.direction),
up: Cesium.Cartesian3.clone(camera.upWC || camera.up),
}
}
function restoreCameraView(snapshot) {
if (!viewer?.camera || !snapshot || !window.Cesium) return
const Cesium = window.Cesium
viewer.camera.cancelFlight?.()
viewer.camera.lookAtTransform?.(Cesium.Matrix4.IDENTITY)
viewer.camera.setView({
destination: Cesium.Cartesian3.clone(snapshot.position),
orientation: {
direction: Cesium.Cartesian3.clone(snapshot.direction),
up: Cesium.Cartesian3.clone(snapshot.up),
},
})
viewer.scene?.requestRender?.()
}
function scheduleCameraRestore(snapshot) {
if (!snapshot || typeof window === "undefined") return
window.requestAnimationFrame?.(() => restoreCameraView(snapshot))
window.setTimeout?.(() => restoreCameraView(snapshot), 80)
}
function resetView() {
clearSelectedFeature()
setCountyView(true)
}
function setCountyView(animated = true) {
if (!viewer) return
const Cesium = window.Cesium
const center = getCountyCenter()
const range = zoomToAltitude(props.initialZoom)
const targetOffset = getCountyViewTargetOffset()
const target = Cesium.Cartesian3.fromDegrees(center[0] + targetOffset.lng, center[1] + targetOffset.lat, 2200)
const offset = new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(COUNTY_VIEW_HEADING),
Cesium.Math.toRadians(COUNTY_VIEW_PITCH),
range,
)
if (animated) {
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 1), { offset, duration: 0.9 })
} else {
viewer.camera.lookAt(target, offset)
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY)
}
viewer.scene.requestRender()
}
function getCountyViewTargetOffset() {
const offset = props.countyViewTargetOffset
const lng = Number(offset?.lng ?? offset?.[0])
const lat = Number(offset?.lat ?? offset?.[1])
return {
lng: Number.isFinite(lng) ? lng : 0,
lat: Number.isFinite(lat) ? lat : -COUNTY_VIEW_CENTER_LAT_OFFSET,
}
}
function zoomToAltitude(zoom) {
const normalized = Number.isFinite(Number(zoom)) ? Number(zoom) : 9
return Math.max(9000, Math.min(580000, 365000 / Math.pow(1.62, normalized - 9)))
}
function getCountyCenter() {
const configuredCenter = normalizeLngLat(props.center)
if (configuredCenter) return configuredCenter
return [
(HONGYUAN_BOUNDS.west + HONGYUAN_BOUNDS.east) / 2,
(HONGYUAN_BOUNDS.south + HONGYUAN_BOUNDS.north) / 2,
]
}
function connectResizeObserver() {
if (!containerRef.value || !window.ResizeObserver) return
resizeObserver = new ResizeObserver(() => refreshView())
resizeObserver.observe(containerRef.value)
}
function connectBoundaryVisualScale() {
if (!viewer || cameraMoveEndRemove) return
cameraMoveEndRemove = viewer.camera.moveEnd.addEventListener(updateBoundaryVisualScale)
}
function updateBoundaryVisualScale() {
if (!viewer || viewer.isDestroyed?.()) return
const height = Number(viewer.camera.positionCartographic?.height) || 320000
const nearWeight = Math.max(0, Math.min(1, (360000 - height) / 300000))
const widths = {
township: 1.1 + nearWeight * 0.35,
countyOutline: 1.9 + nearWeight * 0.35,
countyHalo: 4.2 + nearWeight * 0.65,
}
boundaryEntities.forEach((entity) => {
if (entity?.polyline && entity.__hyBoundaryRole === "township") entity.polyline.width = widths.township
})
countyFrameEntities.forEach((entity) => {
if (entity?.polyline && entity.__hyBoundaryRole === "county-outline") entity.polyline.width = widths.countyOutline
if (entity?.polyline && entity.__hyBoundaryRole === "county-halo") entity.polyline.width = widths.countyHalo
})
viewer.scene.requestRender()
}
async function refreshTopicLayers() {
if (!viewer) return
const revision = ++layerRevision
clearTopicLayers()
const layers = activeLayerStack.value
if (!layers.length) {
emit("layer-status", { type: "idle", text: "未选择专题图层" })
viewer.scene.requestRender()
return
}
emit("layer-status", { type: "loading", text: "正在加载三维专题图层" })
try {
await loadCountyBoundary()
if (revision !== layerRevision || !viewer) return
for (const layer of layers) {
if (revision !== layerRevision) return
await addTopicLayer(layer, revision)
}
if (revision !== layerRevision) return
if (props.autoApplyLayerMapView) applyLayerMapView(layers.find((item) => item?.mapView || item?.cameraPosition))
emit("layer-status", { type: "success", text: `${layers.map((item) => item.name).filter(Boolean).join("、")}已加载` })
} catch (error) {
if (revision !== layerRevision) return
console.warn("[cesium-map] topic layer failed", error)
emit("layer-status", { type: "error", text: "三维专题图层加载失败" })
} finally {
viewer?.scene?.requestRender?.()
}
}
async function addTopicLayer(layer, revision) {
const sourceType = String(layer?.sourceType || "wms").toLowerCase()
if (["geojson", "wkt-api", "wfs"].includes(sourceType)) {
const geojson = await loadVectorLayerData(layer)
if (revision !== layerRevision || !geojson) return
addFeatureCollection(geojson, layer, topicEntities)
return
}
if (sourceType === "wmts") {
addWmtsLayer(layer)
return
}
addWmsLayer(layer)
}
function addWmtsLayer(layer) {
const Cesium = window.Cesium
const tileMatrixSetID = layer.tileMatrixSetID || layer.tileMatrixSet || "EPSG:900913"
const maximumLevel = Number.isFinite(Number(layer.maximumLevel)) ? Number(layer.maximumLevel) : 21
const provider = new Cesium.WebMapTileServiceImageryProvider({
url: layer.url || "/geoserver/gwc/service/wmts",
layer: layer.layerName,
style: layer.style || layer.styles || "",
format: layer.format || "image/png",
tileMatrixSetID,
tileMatrixLabels: layer.tileMatrixLabels || buildWmtsTileMatrixLabels(tileMatrixSetID, maximumLevel),
tilingScheme: /4326/i.test(tileMatrixSetID)
? new Cesium.GeographicTilingScheme()
: new Cesium.WebMercatorTilingScheme(),
maximumLevel,
credit: layer.credit || "",
})
const imageryLayer = viewer.imageryLayers.addImageryProvider(provider)
const isYakRecognition = String(layer.layerName || "").includes("daping_yak_marks")
imageryLayer.alpha = isYakRecognition
? clampOpacity(layer.opacity, 0.94)
: clampOpacity(layer.opacity, 0.86)
if (isYakRecognition) applyYakRecognitionImageryStyle(imageryLayer, layer)
imageryLayer.__hyLayer = layer
topicImageryLayers.push(imageryLayer)
}
function buildWmtsTileMatrixLabels(tileMatrixSetID, maximumLevel = 21) {
return Array.from({ length: maximumLevel + 1 }, (_, level) => `${tileMatrixSetID}:${level}`)
}
function addWmsLayer(layer) {
const Cesium = window.Cesium
const parameters = {
service: "WMS",
transparent: true,
format: "image/png",
version: "1.1.1",
styles: layer.styles || "",
}
if (layer.cqlFilter) parameters.cql_filter = layer.cqlFilter
if (layer.sldBody) parameters.sld_body = layer.sldBody
const provider = new Cesium.WebMapServiceImageryProvider({
url: layer.url || "/geoserver/ne/wms",
layers: layer.layerName,
parameters,
getFeatureInfoParameters: {
...parameters,
info_format: "application/json",
feature_count: 10,
},
})
const imageryLayer = viewer.imageryLayers.addImageryProvider(provider)
const isYakRecognition = String(layer.layerName || "").includes("daping_yak_marks")
imageryLayer.alpha = isYakRecognition
? clampOpacity(layer.opacity, 0.94)
: clampOpacity(layer.opacity, 0.86)
if (isYakRecognition) applyYakRecognitionImageryStyle(imageryLayer, layer)
imageryLayer.__hyLayer = layer
topicImageryLayers.push(imageryLayer)
}
function applyYakRecognitionImageryStyle(imageryLayer, layer = {}) {
imageryLayer.brightness = Number.isFinite(Number(layer.brightness)) ? Number(layer.brightness) : 1.08
imageryLayer.contrast = Number.isFinite(Number(layer.contrast)) ? Number(layer.contrast) : 1.18
imageryLayer.saturation = Number.isFinite(Number(layer.saturation)) ? Number(layer.saturation) : 1.42
imageryLayer.gamma = Number.isFinite(Number(layer.gamma)) ? Number(layer.gamma) : 0.95
}
async function loadVectorLayerData(layer) {
if (layer.sourceType === "wkt-api") {
try {
const response = await fetch(layer.apiUrl, { credentials: "same-origin" })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return normalizeWktPayload(await response.json())
} catch (error) {
if (!layer.fallbackPath) throw error
return fetchJson(layer.fallbackPath)
}
}
if (layer.sourceType === "wfs") {
const url = buildWfsUrl(layer)
return fetchJson(url)
}
return fetchJson(layer.dataPath || layer.fallbackPath)
}
async function fetchJson(path) {
if (!path) return null
const url = /^(https?:)?\/\//.test(path) || path.startsWith("/") ? path : getPublicMapResourcePath(path)
const response = await fetch(url, { credentials: "same-origin" })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
function buildWfsUrl(layer) {
const params = new URLSearchParams({
service: "WFS",
version: "1.1.0",
request: "GetFeature",
typeName: layer.layerName,
outputFormat: "application/json",
srsName: "EPSG:4326",
})
if (layer.cqlFilter) params.set("cql_filter", layer.cqlFilter)
if (layer.maxFeatures) params.set("maxFeatures", String(layer.maxFeatures))
if (Array.isArray(layer.propertyName) && layer.propertyName.length) params.set("propertyName", layer.propertyName.join(","))
else if (layer.propertyName) params.set("propertyName", layer.propertyName)
return `${layer.url || "/geoserver/ne/wfs"}?${params.toString()}`
}
function normalizeImageryFeatureInfo(info, properties = {}, layer = null, lngLat = null) {
const sourceFeature = info?.data?.type === "Feature"
? info.data
: info?.data?.features?.[0]
|| info?.feature
|| null
const sourceProperties = sourceFeature?.properties || properties || {}
const geometry = normalizeGeometry(
sourceFeature?.geometry
|| sourceProperties.geometry
|| sourceProperties.geom
|| sourceProperties.the_geom
|| sourceProperties.theGeom
|| sourceProperties.wkt
|| sourceProperties.shape
)
const id = sourceFeature?.id
|| sourceProperties.id
|| sourceProperties.gid
|| sourceProperties.objectid
|| sourceProperties.OBJECTID
|| info?.name
return {
type: "Feature",
id,
properties: sourceProperties,
feature: {
type: "Feature",
id,
properties: sourceProperties,
geometry,
},
geometry,
layer,
lngLat,
}
}
function normalizeWktPayload(payload) {
const body = payload?.data ?? payload
const rows = Array.isArray(body)
? body
: body?.rows || body?.list || body?.records || body?.data || []
return {
type: "FeatureCollection",
features: rows.map((row, index) => ({
type: "Feature",
id: row?.id ?? index,
properties: { ...row },
geometry: normalizeGeometry(row?.geometry) || parseWktGeometry(pickWkt(row)),
})).filter((feature) => feature.geometry),
}
}
function pickWkt(row = {}) {
return row.wkt || row.WKT || row.geom || row.the_geom || row.theGeom || row.shape || row.Shape
}
function addFeatureCollection(data, layer, target) {
const features = data?.type === "FeatureCollection" ? data.features : data?.type === "Feature" ? [data] : []
features
.filter((feature) => feature?.geometry)
.filter((feature) => matchesActiveLegend(feature.properties || {}, layer))
.map((feature) => clipFeatureForLayer(feature, layer))
.filter(Boolean)
.forEach((feature) => addGeoFeature(feature, layer, target))
}
function clipFeatureForLayer(feature, layer) {
const geometry = normalizeGeometry(feature?.geometry)
const shouldClipToCounty = layer?.clipToHongyuanBoundary === true
const clipBounds = getLayerClipBounds(layer)
if (!geometry || !["Polygon", "MultiPolygon"].includes(geometry.type)) return feature
if (!shouldClipToCounty && !clipBounds) return feature
let polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates
try {
if (shouldClipToCounty && hongyuanCountyClipMultiPolygon.length) {
polygons = polygonClipping.intersection(polygons, hongyuanCountyClipMultiPolygon)
}
if (clipBounds && polygons.length) {
polygons = polygonClipping.intersection(polygons, [createClipBoundsPolygon(clipBounds)])
}
} catch (error) {
console.warn("[cesium-map] vector layer clipping failed", layer?.key, error)
return feature
}
if (!polygons.length) return null
return {
...feature,
geometry: {
type: "MultiPolygon",
coordinates: polygons,
},
}
}
function getLayerClipBounds(layer) {
if (!layer?.clipBounds || layer.clipToLayerBounds === false) return null
const bounds = layer.clipBounds
const west = Math.max(Number(bounds.west), HONGYUAN_BOUNDS.west)
const south = Math.max(Number(bounds.south), HONGYUAN_BOUNDS.south)
const east = Math.min(Number(bounds.east), HONGYUAN_BOUNDS.east)
const north = Math.min(Number(bounds.north), HONGYUAN_BOUNDS.north)
if (![west, south, east, north].every(Number.isFinite) || west >= east || south >= north) return null
return { west, south, east, north }
}
function createClipBoundsPolygon(bounds) {
return [[
[bounds.west, bounds.south],
[bounds.east, bounds.south],
[bounds.east, bounds.north],
[bounds.west, bounds.north],
[bounds.west, bounds.south],
]]
}
function addGeoFeature(feature, layer, target, overrides = {}) {
const geometry = normalizeGeometry(feature.geometry)
if (!geometry) return []
const style = resolveFeatureStyle(layer, feature.properties || {}, overrides)
const entities = addGeometryEntities(geometry, feature, layer, style)
target.push(...entities)
return entities
}
function addGeometryEntities(geometry, feature, layer, style) {
const Cesium = window.Cesium
const created = []
const add = (options) => {
const entity = viewer.entities.add(options)
entity.__hyFeature = feature
entity.__hyLayer = layer
created.push(entity)
return entity
}
if (geometry.type === "Point") {
const point = normalizeLngLat(geometry.coordinates)
if (!point || layer.hidePointFeatures || layer.hidePointMarkers) return created
add({
position: Cesium.Cartesian3.fromDegrees(point[0], point[1], 8),
point: {
pixelSize: style.pointSize,
color: style.fill,
outlineColor: style.stroke,
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: layer.showPointLabels ? createPointLabel(feature, style) : undefined,
})
return created
}
if (geometry.type === "MultiPoint") {
geometry.coordinates.forEach((coordinates) => created.push(...addGeometryEntities({ type: "Point", coordinates }, feature, layer, style)))
return created
}
if (geometry.type === "LineString") {
const positions = toCartesianPositions(geometry.coordinates)
if (positions.length < 2) return created
add({
polyline: {
positions,
width: style.lineWidth,
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.16, color: style.stroke }),
clampToGround: true,
},
})
return created
}
if (geometry.type === "MultiLineString") {
geometry.coordinates.forEach((coordinates) => created.push(...addGeometryEntities({ type: "LineString", coordinates }, feature, layer, style)))
return created
}
if (geometry.type === "Polygon") {
const hierarchy = createPolygonHierarchy(geometry.coordinates)
if (!hierarchy) return created
add({
polygon: {
hierarchy,
height: 0,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
perPositionHeight: false,
material: style.fill,
outline: false,
classificationType: Cesium.ClassificationType.BOTH,
},
})
addPolygonBoundaryLines(geometry.coordinates, feature, layer, style, created)
return created
}
if (geometry.type === "MultiPolygon") {
geometry.coordinates.forEach((coordinates) => created.push(...addGeometryEntities({ type: "Polygon", coordinates }, feature, layer, style)))
}
return created
}
function addPolygonBoundaryLines(rings, feature, layer, style, created) {
const Cesium = window.Cesium
rings.forEach((ring) => {
const positions = toCartesianPositions(ring)
if (positions.length < 2) return
const entity = viewer.entities.add({
polyline: {
positions,
width: style.lineWidth,
material: style.stroke,
depthFailMaterial: style.stroke,
clampToGround: true,
},
})
entity.__hyFeature = feature
entity.__hyLayer = layer
created.push(entity)
})
}
function createPolygonHierarchy(rings = []) {
const Cesium = window.Cesium
const outer = toCartesianPositions(rings[0] || [])
if (outer.length < 3) return null
const holes = rings.slice(1).map((ring) => {
const positions = toCartesianPositions(ring)
return positions.length >= 3 ? new Cesium.PolygonHierarchy(positions) : null
}).filter(Boolean)
return new Cesium.PolygonHierarchy(outer, holes)
}
function createPointLabel(feature, style) {
const Cesium = window.Cesium
const properties = feature.properties || {}
const name = properties.name || properties.project_name || properties.title || ""
if (!name) return undefined
return {
text: String(name),
font: "600 14px PingFang SC, Microsoft YaHei, sans-serif",
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.fromCssColorString("#03121e"),
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -22),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 150000),
}
}
function resolveFeatureStyle(layer, properties, overrides = {}) {
const Cesium = window.Cesium
const legend = resolveLegend(layer, properties)
const opacity = clampOpacity(layer.opacity, 0.84)
const fillText = overrides.fillColor || legend?.fillColor || layer.fillColor || layer.color || "#24F6C4"
const strokeText = overrides.strokeColor || legend?.strokeColor || layer.strokeColor || layer.color || "#B7FFF2"
const fillAlpha = overrides.fillAlpha ?? legend?.fillAlpha ?? layer.fillAlpha ?? Math.min(0.34, opacity * 0.34)
const strokeAlpha = overrides.strokeAlpha ?? legend?.strokeAlpha ?? layer.strokeAlpha ?? Math.max(0.72, opacity)
const lineWidth = overrides.lineWidth ?? legend?.lineWidth ?? layer.lineWidth ?? Math.max(1.6, 1.8 * Number(props.adminBoundaryLineScale || 1))
return {
fill: colorWithAlpha(Cesium, fillText, fillAlpha),
stroke: colorWithAlpha(Cesium, strokeText, strokeAlpha),
lineWidth,
pointSize: overrides.pointSize || 12,
}
}
function colorWithAlpha(Cesium, value, alpha) {
try {
return Cesium.Color.fromCssColorString(String(value || "#24F6C4")).withAlpha(clampOpacity(alpha, 1))
} catch (error) {
return Cesium.Color.CYAN.withAlpha(clampOpacity(alpha, 1))
}
}
function resolveLegend(layer, properties) {
if (layer?.activeLegend) return layer.activeLegend
return (layer?.legends || []).find((legend) => matchesLegend(properties, legend)) || layer?.legends?.[0]
}
function matchesActiveLegend(properties, layer) {
const legend = layer?.activeLegend
if (!legend) return true
return matchesLegend(properties, legend)
}
function matchesLegend(properties, legend = {}) {
if (legend.match) return matchesProperty(properties, legend.match)
if (Array.isArray(legend.matches)) return legend.matches.some((item) => matchesProperty(properties, item))
return true
}
function matchesProperty(properties, matcher = {}) {
return String(properties?.[matcher.field] ?? "") === String(matcher.value ?? "")
}
function addCountyStage() {
if (!viewer) return
removeEntities(countyStageEntities)
const Cesium = window.Cesium
const center = getCountyCenter()
const stageMaterial = new Cesium.ImageMaterialProperty({
image: createStageGlowCanvas().toDataURL("image/png"),
transparent: true,
})
const stageBase = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(center[0], center[1], COUNTY_STAGE_HEIGHT),
ellipse: {
semiMajorAxis: 108000,
semiMinorAxis: 64000,
rotation: Cesium.Math.toRadians(-23),
height: COUNTY_STAGE_HEIGHT,
material: stageMaterial,
outline: false,
},
})
const orbitStyles = [
{ major: 104000, minor: 62000, height: COUNTY_STAGE_HEIGHT + 1800, width: 1.7, alpha: 0.08, glowPower: 0.14 },
{ major: 84000, minor: 50000, height: COUNTY_STAGE_HEIGHT + 2500, width: 1.2, alpha: 0.07, glowPower: 0.12 },
]
const orbitEntities = orbitStyles.map((style) => viewer.entities.add({
polyline: {
positions: makeEllipsePositions(center, style.major, style.minor, -23, style.height, 192),
width: style.width,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: style.glowPower,
color: Cesium.Color.fromCssColorString("#39E8FF").withAlpha(style.alpha),
}),
clampToGround: false,
},
}))
const arcEntities = [
{ major: 112000, minor: 68000, start: 212, end: 266, alpha: 0.16 },
{ major: 112000, minor: 68000, start: 31, end: 72, alpha: 0.13 },
{ major: 90000, minor: 54000, start: 322, end: 360, alpha: 0.12 },
].map((arc) => viewer.entities.add({
polyline: {
positions: makeEllipseArcPositions(center, arc.major, arc.minor, -23, arc.start, arc.end, COUNTY_STAGE_HEIGHT + 3500, 64),
width: 3.4,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.28,
color: Cesium.Color.fromCssColorString("#7BFFF5").withAlpha(arc.alpha),
}),
clampToGround: false,
},
}))
countyStageEntities.push(stageBase, ...orbitEntities, ...arcEntities)
viewer.scene.requestRender()
}
function createStageGlowCanvas() {
const canvas = document.createElement("canvas")
canvas.width = 1024
canvas.height = 640
const context = canvas.getContext("2d")
if (!context) return canvas
const centerX = canvas.width / 2
const centerY = canvas.height / 2
context.clearRect(0, 0, canvas.width, canvas.height)
const glow = context.createRadialGradient(centerX, centerY, 20, centerX, centerY, canvas.width * 0.48)
glow.addColorStop(0, "rgba(39, 220, 255, 0.2)")
glow.addColorStop(0.32, "rgba(18, 156, 203, 0.12)")
glow.addColorStop(0.68, "rgba(14, 86, 139, 0.08)")
glow.addColorStop(1, "rgba(8, 30, 58, 0)")
context.fillStyle = glow
context.fillRect(0, 0, canvas.width, canvas.height)
context.save()
context.translate(centerX, centerY)
context.scale(1.45, 0.84)
context.strokeStyle = "rgba(81, 226, 255, 0.2)"
context.lineWidth = 3
context.beginPath()
context.arc(0, 0, 210, 0, Math.PI * 2)
context.stroke()
context.strokeStyle = "rgba(81, 226, 255, 0.1)"
context.lineWidth = 1.3
for (let radius = 90; radius <= 260; radius += 42) {
context.beginPath()
context.arc(0, 0, radius, 0, Math.PI * 2)
context.stroke()
}
context.restore()
return canvas
}
function makeEllipsePositions(center, semiMajorMeters, semiMinorMeters, rotationDeg, height, segments = 160) {
return makeEllipseArcPositions(center, semiMajorMeters, semiMinorMeters, rotationDeg, 0, 360, height, segments)
}
function makeEllipseArcPositions(center, semiMajorMeters, semiMinorMeters, rotationDeg, startDeg, endDeg, height, segments = 64) {
const Cesium = window.Cesium
const centerLng = Number(center[0])
const centerLat = Number(center[1])
const cosLat = Math.max(0.2, Math.cos(Cesium.Math.toRadians(centerLat)))
const rotation = Cesium.Math.toRadians(rotationDeg)
const start = Cesium.Math.toRadians(startDeg)
const end = Cesium.Math.toRadians(endDeg)
const positions = []
for (let index = 0; index <= segments; index += 1) {
const t = start + (end - start) * (index / segments)
const x = semiMajorMeters * Math.cos(t)
const y = semiMinorMeters * Math.sin(t)
const rotatedX = x * Math.cos(rotation) - y * Math.sin(rotation)
const rotatedY = x * Math.sin(rotation) + y * Math.cos(rotation)
const lng = centerLng + rotatedX / (111320 * cosLat)
const lat = centerLat + rotatedY / 110540
positions.push(Cesium.Cartesian3.fromDegrees(lng, lat, height))
}
return positions
}
function addTownshipBoundaries() {
if (!viewer) return
removeEntities(boundaryEntities)
const Cesium = window.Cesium
const style = {
stroke: Cesium.Color.fromCssColorString("#B8FFF8").withAlpha(0.82),
fill: Cesium.Color.TRANSPARENT,
lineWidth: 1.2,
pointSize: 0,
}
createTownshipInternalBoundarySegments(hongyuanTownshipsGeoJson.features || []).forEach((segment) => {
const positions = toRaisedCartesianPositions(segment.points, TOWNSHIP_BOUNDARY_RENDER_HEIGHT)
if (positions.length < 2) return
const entity = viewer.entities.add({
polyline: {
positions,
width: style.lineWidth,
material: style.stroke,
depthFailMaterial: style.stroke,
clampToGround: false,
},
})
entity.__hyBoundaryRole = "township"
entity.__hyTownName = segment.townNames?.[0] || ""
boundaryEntities.push(entity)
})
viewer.scene.requestRender()
}
function createTownshipInternalBoundarySegments(features = []) {
const segmentMap = new Map()
features.forEach((feature) => {
const townName = String(feature?.properties?.name || "").trim()
const geometry = normalizeGeometry(feature?.geometry)
getGeometryPolygons(geometry).forEach((polygon) => {
const ring = (polygon?.[0] || []).map(normalizeLngLat).filter(Boolean)
for (let index = 0; index < ring.length - 1; index += 1) {
const start = ring[index]
const end = ring[index + 1]
if (!start || !end || isSameLngLat(start, end)) continue
const key = getBoundarySegmentKey(start, end)
const existing = segmentMap.get(key) || {
points: [start, end],
count: 0,
townNames: new Set(),
}
existing.count += 1
if (townName) existing.townNames.add(townName)
segmentMap.set(key, existing)
}
})
})
const internalSegments = Array.from(segmentMap.values())
.map((segment) => ({
...segment,
townNames: Array.from(segment.townNames),
}))
.filter((segment) => segment.count > 1 && segment.townNames.length > 1)
return mergeBoundarySegments(internalSegments)
}
function getGeometryPolygons(geometry) {
if (!geometry?.coordinates) return []
if (geometry.type === "Polygon") return [geometry.coordinates]
if (geometry.type === "MultiPolygon") return geometry.coordinates
return []
}
function getBoundarySegmentKey(start, end) {
const first = getBoundaryPointKey(start)
const second = getBoundaryPointKey(end)
return first < second ? `${first}|${second}` : `${second}|${first}`
}
function getBoundaryPointKey(point) {
return `${Number(point[0]).toFixed(5)},${Number(point[1]).toFixed(5)}`
}
function isSameLngLat(start, end) {
return Math.abs(Number(start[0]) - Number(end[0])) < 1e-10
&& Math.abs(Number(start[1]) - Number(end[1])) < 1e-10
}
function mergeBoundarySegments(segments = []) {
const endpointMap = new Map()
segments.forEach((segment, index) => {
segment.points.forEach((point) => {
const key = getBoundaryPointKey(point)
const indexes = endpointMap.get(key) || []
indexes.push(index)
endpointMap.set(key, indexes)
})
})
const used = new Set()
const merged = []
segments.forEach((segment, index) => {
if (used.has(index)) return
used.add(index)
const path = [segment.points[0], segment.points[1]]
extendBoundaryPath(path, endpointMap, segments, used, true)
extendBoundaryPath(path, endpointMap, segments, used, false)
merged.push({
points: path,
townNames: segment.townNames,
})
})
return merged
}
function extendBoundaryPath(path, endpointMap, segments, used, atStart) {
while (path.length) {
const endpoint = atStart ? path[0] : path[path.length - 1]
const endpointKey = getBoundaryPointKey(endpoint)
const candidates = (endpointMap.get(endpointKey) || []).filter((index) => !used.has(index))
if (candidates.length !== 1) return
const segmentIndex = candidates[0]
const segment = segments[segmentIndex]
used.add(segmentIndex)
const [start, end] = segment.points
const nextPoint = getBoundaryPointKey(start) === endpointKey ? end : start
if (atStart) path.unshift(nextPoint)
else path.push(nextPoint)
}
}
function addCountyFrame() {
if (!viewer || !hongyuanCountyClipMultiPolygon.length) return
removeEntities(countyFrameEntities)
const Cesium = window.Cesium
addCountyOutsideMask(Cesium)
hongyuanCountyClipMultiPolygon.forEach((polygon) => {
const outerRing = Array.isArray(polygon?.[0]) ? polygon[0] : []
const ring = outerRing.map(normalizeLngLat).filter(Boolean)
if (ring.length < 3) return
const hierarchy = createPolygonHierarchy(polygon)
if (hierarchy) {
const topWashEntity = viewer.entities.add({
polygon: {
hierarchy,
material: Cesium.Color.fromCssColorString("#20EAF2").withAlpha(0.026),
outline: false,
classificationType: Cesium.ClassificationType.BOTH,
},
})
countyFrameEntities.push(topWashEntity)
}
const haloEntity = viewer.entities.add({
polyline: {
positions: toRaisedCartesianPositions(ring, COUNTY_BOUNDARY_RENDER_HEIGHT),
width: 4.8,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.16,
color: Cesium.Color.fromCssColorString("#78FFF7").withAlpha(0.48),
}),
depthFailMaterial: Cesium.Color.fromCssColorString("#78FFF7").withAlpha(0.32),
clampToGround: false,
zIndex: 8,
},
})
haloEntity.__hyBoundaryRole = "county-halo"
const outlineEntity = viewer.entities.add({
polyline: {
positions: toRaisedCartesianPositions(ring, COUNTY_BOUNDARY_RENDER_HEIGHT + 80),
width: 1.9,
material: Cesium.Color.fromCssColorString("#F2FFFB").withAlpha(0.98),
depthFailMaterial: Cesium.Color.fromCssColorString("#F2FFFB").withAlpha(0.9),
clampToGround: false,
zIndex: 9,
},
})
outlineEntity.__hyBoundaryRole = "county-outline"
countyFrameEntities.push(haloEntity, outlineEntity)
})
viewer.scene.requestRender()
}
function addCountyOutsideMask(Cesium) {
const outerMask = createCountyOutsideMaskPolygon()
let maskPolygons = []
try {
maskPolygons = polygonClipping.difference([outerMask], hongyuanCountyClipMultiPolygon)
} catch (error) {
console.warn("[cesium-map] county outside mask failed", error)
maskPolygons = [outerMask]
}
maskPolygons.forEach((polygon) => {
const hierarchy = createPolygonHierarchy(polygon)
if (!hierarchy) return
const maskEntity = viewer.entities.add({
polygon: {
hierarchy,
material: Cesium.Color.fromCssColorString("#020A10").withAlpha(0.52),
outline: false,
perPositionHeight: false,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
classificationType: Cesium.ClassificationType.BOTH,
zIndex: 1,
},
})
countyFrameEntities.push(maskEntity)
})
}
function createCountyOutsideMaskPolygon() {
const west = HONGYUAN_BOUNDS.west - COUNTY_MASK_PADDING_LNG
const south = HONGYUAN_BOUNDS.south - COUNTY_MASK_PADDING_LAT
const east = HONGYUAN_BOUNDS.east + COUNTY_MASK_PADDING_LNG
const north = HONGYUAN_BOUNDS.north + COUNTY_MASK_PADDING_LAT
return [[
[west, south],
[east, south],
[east, north],
[west, north],
[west, south],
]]
}
function getTownBadgeImage(labelLines, highlighted = false) {
const lines = (Array.isArray(labelLines) ? labelLines : [labelLines])
.map((line) => String(line || "").trim())
.filter(Boolean)
.slice(0, 2)
const cacheKey = `v15-centered-map-badge:${highlighted ? "h" : "n"}:${lines.join("|")}`
if (townBadgeImageCache.has(cacheKey)) return townBadgeImageCache.get(cacheKey)
const canvas = document.createElement("canvas")
const context = canvas.getContext("2d")
if (!context) return { image: "", width: 1, height: 1 }
const pixelRatio = Math.min(4, Math.max(3, window.devicePixelRatio || 2))
const mainFont = "700 14px Microsoft YaHei, PingFang SC, sans-serif"
const metricFont = "700 9px Microsoft YaHei, PingFang SC, sans-serif"
const valueFont = "800 14px Microsoft YaHei, PingFang SC, sans-serif"
const unitFont = "600 9px Microsoft YaHei, PingFang SC, sans-serif"
const statParts = parseTownBadgeStatLine(lines[1])
context.font = mainFont
const mainWidth = Math.ceil(context.measureText(lines[0] || "").width)
let statWidth = 0
if (statParts) {
context.font = metricFont
statWidth += Math.ceil(context.measureText(statParts.metric).width)
context.font = valueFont
statWidth += Math.ceil(context.measureText(statParts.value).width)
context.font = unitFont
statWidth += Math.ceil(context.measureText(statParts.unit).width)
statWidth += statParts.metric ? 5 : 0
statWidth += statParts.unit ? 4 : 0
}
const bodyWidth = Math.min(118, Math.max(statParts ? 96 : 62, mainWidth + 20, statWidth + 20))
const bodyHeight = statParts ? 40 : 25
const pointerHeight = statParts ? 5 : 0
const margin = 6
const width = bodyWidth + margin * 2
const height = bodyHeight + pointerHeight + margin * 2
canvas.width = Math.ceil(width * pixelRatio)
canvas.height = Math.ceil(height * pixelRatio)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
context.scale(pixelRatio, pixelRatio)
context.clearRect(0, 0, width, height)
const x = margin
const y = margin
const bodyBottom = y + bodyHeight
const gradient = context.createLinearGradient(x, y, x, bodyBottom)
gradient.addColorStop(0, highlighted ? "rgba(8, 58, 70, 0.9)" : "rgba(6, 45, 57, 0.78)")
gradient.addColorStop(0.55, highlighted ? "rgba(4, 34, 46, 0.88)" : "rgba(3, 29, 41, 0.76)")
gradient.addColorStop(1, highlighted ? "rgba(2, 22, 32, 0.88)" : "rgba(2, 18, 29, 0.78)")
context.save()
context.shadowColor = "rgba(48, 220, 255, 0.13)"
context.shadowBlur = 6
context.fillStyle = gradient
drawBeveledBadgePath(context, x, y, bodyWidth, bodyHeight)
context.fill()
context.restore()
context.save()
context.globalCompositeOperation = "lighter"
const sheen = context.createLinearGradient(x, y, x + bodyWidth, y)
sheen.addColorStop(0, "rgba(42, 210, 255, 0)")
sheen.addColorStop(0.5, highlighted ? "rgba(98, 246, 255, 0.34)" : "rgba(98, 246, 255, 0.26)")
sheen.addColorStop(1, "rgba(42, 210, 255, 0)")
context.strokeStyle = sheen
context.lineWidth = 1
context.beginPath()
context.moveTo(x + 10, y + 2)
context.lineTo(x + bodyWidth - 10, y + 2)
context.stroke()
context.restore()
context.save()
context.strokeStyle = highlighted ? "rgba(126, 251, 246, 0.58)" : "rgba(126, 251, 246, 0.46)"
context.lineWidth = 0.9
drawBeveledBadgePath(context, x + 0.5, y + 0.5, bodyWidth - 1, bodyHeight - 1)
context.stroke()
context.restore()
const pointerX = x + bodyWidth / 2
if (pointerHeight > 0) {
context.save()
context.shadowColor = "rgba(48, 220, 255, 0.2)"
context.shadowBlur = 5
context.beginPath()
context.moveTo(pointerX - 5, bodyBottom - 1)
context.lineTo(pointerX + 5, bodyBottom - 1)
context.lineTo(pointerX, bodyBottom + pointerHeight)
context.closePath()
context.fillStyle = highlighted ? "rgba(4, 42, 52, 0.98)" : "rgba(3, 31, 43, 0.96)"
context.fill()
context.strokeStyle = highlighted ? "rgba(126, 251, 246, 0.48)" : "rgba(126, 251, 246, 0.34)"
context.lineWidth = 0.8
context.stroke()
context.restore()
}
context.save()
context.textAlign = "center"
context.textBaseline = "alphabetic"
context.fillStyle = "rgba(255, 255, 255, 0.98)"
context.strokeStyle = "rgba(0, 8, 14, 0.98)"
context.lineJoin = "round"
context.shadowColor = "rgba(0, 8, 14, 0.72)"
context.shadowBlur = 0
context.font = mainFont
context.lineWidth = 1.6
const mainTextY = y + (statParts ? 12.5 : bodyHeight / 2)
drawCenteredCanvasText(context, lines[0] || "", pointerX, mainTextY)
if (statParts) {
const statY = y + 29.5
const totalStatWidth = statWidth
let statX = pointerX - totalStatWidth / 2
context.textAlign = "left"
context.shadowBlur = 0
context.font = metricFont
context.lineWidth = 1.2
context.fillStyle = "rgba(126, 251, 246, 0.9)"
if (statParts.metric) {
drawCenteredCanvasText(context, statParts.metric, statX, statY, { stroke: false })
context.font = metricFont
statX += Math.ceil(context.measureText(statParts.metric).width) + 5
}
context.font = valueFont
context.lineWidth = 1.4
context.fillStyle = "#FFFFFF"
context.shadowColor = "rgba(0, 8, 14, 0.72)"
context.shadowBlur = 0
drawCenteredCanvasText(context, statParts.value, statX, statY)
context.shadowBlur = 0
statX += Math.ceil(context.measureText(statParts.value).width) + (statParts.unit ? 4 : 0)
if (statParts.unit) {
context.font = unitFont
context.fillStyle = "rgba(232, 252, 255, 0.76)"
drawCenteredCanvasText(context, statParts.unit, statX, statY + 1, { stroke: false })
}
}
context.restore()
const badge = {
image: canvas.toDataURL("image/png"),
width,
height,
}
townBadgeImageCache.set(cacheKey, badge)
return badge
}
function drawCenteredCanvasText(context, text, x, centerY, options = {}) {
const metrics = context.measureText(text)
const fontSize = getCanvasFontSize(context.font)
const ascent = Number(metrics.actualBoundingBoxAscent) || fontSize * 0.74
const descent = Number(metrics.actualBoundingBoxDescent) || fontSize * 0.18
const baselineY = centerY + (ascent - descent) / 2
if (options.stroke !== false && context.lineWidth > 0) {
context.strokeText(text, x, baselineY)
}
context.fillText(text, x, baselineY)
}
function getCanvasFontSize(font) {
const match = String(font || "").match(/(\d+(?:\.\d+)?)px/)
return match ? Number(match[1]) : 14
}
function parseTownBadgeStatLine(line) {
const text = String(line || "").trim()
if (!text) return null
const spacedMatch = text.match(/^(.+?)\s+([+-]?\d[\d,.]*)(.*)$/u)
if (spacedMatch) {
return {
metric: spacedMatch[1].trim(),
value: spacedMatch[2].trim(),
unit: spacedMatch[3].trim(),
}
}
const tailMatch = text.match(/^(.*?)([+-]?\d[\d,.]*)([\u4e00-\u9fa5A-Za-z/%]+)?$/u)
if (tailMatch) {
return {
metric: tailMatch[1].trim(),
value: tailMatch[2].trim(),
unit: (tailMatch[3] || "").trim(),
}
}
return {
metric: "",
value: text,
unit: "",
}
}
function drawBeveledBadgePath(context, x, y, width, height) {
const cut = Math.min(7, width * 0.12, height * 0.34)
context.beginPath()
context.moveTo(x + cut, y)
context.lineTo(x + width - cut, y)
context.lineTo(x + width, y + cut)
context.lineTo(x + width, y + height - cut)
context.lineTo(x + width - cut, y + height)
context.lineTo(x + cut, y + height)
context.lineTo(x, y + height - cut)
context.lineTo(x, y + cut)
context.closePath()
}
function refreshMapRanking() {
if (!viewer) return
removeEntities(rankingEntities)
const Cesium = window.Cesium
const ranking = props.mapRanking || {}
const rows = Array.isArray(ranking.rows) ? ranking.rows : []
const rowByName = new Map(rows.map((row) => [normalizeTownName(row.name), row]))
const showTownNames = ranking.showTownNames !== false && ranking.hideTownNames !== true
const hideTownLabelStats = ranking.hideTownLabelStats ?? props.hideTownLabelStats
if (showTownNames) {
hongyuanTownshipLabelPoints.forEach((town) => {
const row = rowByName.get(normalizeTownName(town.name))
const labelLines = [town.name]
if (!hideTownLabelStats && row && ranking.showMapLabels !== false) {
const metric = ranking.metricLabel ? `${ranking.metricLabel} ` : ""
labelLines.push(`${metric}${formatMapValue(row.value)}${row.unit || ranking.unit || ""}`)
}
const point = normalizeLngLat(town.center)
if (!point) return
const badge = getTownBadgeImage(labelLines, Boolean(row))
const centerBadge = Boolean(hideTownLabelStats || labelLines.length === 1)
const entity = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(point[0], point[1], 18),
billboard: {
image: badge.image,
width: badge.width,
height: badge.height,
scale: 1,
verticalOrigin: centerBadge ? Cesium.VerticalOrigin.CENTER : Cesium.VerticalOrigin.BOTTOM,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
pixelOffset: centerBadge ? new Cesium.Cartesian2(0, 0) : new Cesium.Cartesian2(0, -4),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(20000, 0.94, 360000, 0.8),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 420000),
},
})
entity.__hyTownName = town.name
entity.__hyRankingRow = row || null
rankingEntities.push(entity)
})
}
rows.filter((row) => normalizeRowPoint(row)).forEach((row) => {
const point = normalizeRowPoint(row)
const entity = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(point[0], point[1], 16),
point: {
pixelSize: 9,
color: Cesium.Color.fromCssColorString("#35F3FF"),
outlineColor: Cesium.Color.WHITE,
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: ranking.showPointMarkers === false ? undefined : {
text: String(row.name || ""),
font: "600 12px PingFang SC, Microsoft YaHei, sans-serif",
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.fromCssColorString("#03121e"),
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -18),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
})
entity.__hyTownName = row.name
entity.__hyRankingRow = row
rankingEntities.push(entity)
})
viewer.scene.requestRender()
}
function bindMapClick() {
if (!viewer) return
const Cesium = window.Cesium
clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
clickHandler.setInputAction(async (movement) => {
const canvasPosition = normalizeCanvasPosition(movement.position)
const cartesian = pickGlobePosition(canvasPosition)
if (!cartesian) return
const cartographic = Cesium.Cartographic.fromCartesian(cartesian)
const lngLat = {
lng: Cesium.Math.toDegrees(cartographic.longitude),
lat: Cesium.Math.toDegrees(cartographic.latitude),
height: cartographic.height,
}
const screen = toViewportScreen(canvasPosition)
if (drawingEnabled) {
addDrawingPoint(lngLat)
return
}
emit("map-click", {
lngLat,
screen,
overlayLayer: activeLayerStack.value[0] || null,
})
const picked = viewer.scene.pick(canvasPosition)
const pickedEntity = picked?.id
if (props.enableTownPolygonPick && pickedEntity?.__hyTownName && selectTownByName(pickedEntity.__hyTownName)) return
if (props.enableTownPolygonPick) {
const pickedTownFeature = findSelectableTownshipFeatureByLngLat(lngLat)
if (pickedTownFeature && selectTownByName(pickedTownFeature.properties?.name)) return
}
const pickedVectorEntity = findTopicEntityAtLngLat(lngLat, screen) || pickedEntity
if (pickedVectorEntity?.__hyFeature) {
selectEntity(pickedVectorEntity)
emit("feature-info", {
feature: {
id: pickedVectorEntity.__hyFeature.id,
properties: pickedVectorEntity.__hyFeature.properties || {},
feature: pickedVectorEntity.__hyFeature,
geometry: pickedVectorEntity.__hyFeature.geometry || null,
layer: pickedVectorEntity.__hyLayer,
lngLat,
},
screen,
})
return
}
if (props.enableImageryFeaturePick && topicImageryLayers.length) {
emit("feature-loading", true)
try {
const ray = viewer.camera.getPickRay(canvasPosition)
const features = await viewer.imageryLayers.pickImageryLayerFeatures(ray, viewer.scene)
const info = features?.[0]
if (info) {
const imageryLayer = topicImageryLayers.find((item) => item.imageryProvider === info.imageryLayer?.imageryProvider)
|| topicImageryLayers[topicImageryLayers.length - 1]
const layer = imageryLayer?.__hyLayer || activeLayerStack.value[0]
const properties = info.properties || info.data?.properties || info.data || {}
const pickedFeature = normalizeImageryFeatureInfo(info, properties, layer, lngLat)
const highlightedFeature = pickedFeature
const highlighted = highlightFeature(highlightedFeature)
if (!highlighted) clearSelectedFeature()
emit("feature-info", {
feature: {
id: highlightedFeature.id || properties.id || properties.fid || info.name,
properties: highlightedFeature.properties || properties,
feature: highlightedFeature.feature || info,
geometry: highlightedFeature.geometry || highlightedFeature.feature?.geometry || null,
layer,
lngLat,
},
screen,
})
} else {
clearSelectedFeature()
emit("feature-info", { feature: null, screen })
}
} catch (error) {
clearSelectedFeature()
emit("feature-info", { feature: null, screen })
} finally {
emit("feature-loading", false)
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
}
function normalizeCanvasPosition(position) {
if (!position || !viewer?.scene?.canvas) return position
const Cesium = window.Cesium
const canvas = viewer.scene.canvas
const rect = canvas.getBoundingClientRect?.()
const rectWidth = Number(rect?.width) || 0
const rectHeight = Number(rect?.height) || 0
const canvasWidth = Number(canvas.clientWidth || containerRef.value?.clientWidth) || rectWidth || 1
const canvasHeight = Number(canvas.clientHeight || containerRef.value?.clientHeight) || rectHeight || 1
const x = Number(position.x)
const y = Number(position.y)
if (!Number.isFinite(x) || !Number.isFinite(y) || !rectWidth || !rectHeight) {
return position
}
const scaleX = canvasWidth / rectWidth
const scaleY = canvasHeight / rectHeight
if (Math.abs(scaleX - 1) < 0.001 && Math.abs(scaleY - 1) < 0.001) {
return position
}
return new Cesium.Cartesian2(x * scaleX, y * scaleY)
}
function toViewportScreen(point) {
if (!point || !viewer?.scene?.canvas) return null
const canvas = viewer.scene.canvas
const rect = canvas.getBoundingClientRect?.()
const rectWidth = Number(rect?.width) || 0
const rectHeight = Number(rect?.height) || 0
const canvasWidth = Number(canvas.clientWidth || containerRef.value?.clientWidth) || rectWidth || 1
const canvasHeight = Number(canvas.clientHeight || containerRef.value?.clientHeight) || rectHeight || 1
const x = Number(point.x)
const y = Number(point.y)
if (!Number.isFinite(x) || !Number.isFinite(y) || !rect) return null
return {
x: (Number(rect.left) || 0) + x * (rectWidth / canvasWidth),
y: (Number(rect.top) || 0) + y * (rectHeight / canvasHeight),
}
}
function pickGlobePosition(position) {
if (!viewer) return null
const ray = viewer.camera.getPickRay(position)
return ray ? viewer.scene.globe.pick(ray, viewer.scene) : null
}
function selectEntity(entity) {
clearSelectedFeature()
selectedEntity = entity
if (entity.point) {
entity.__hyOriginalPointSize = entity.point.pixelSize
entity.point.pixelSize = 18
}
if (entity.__hyFeature?.geometry && ["Polygon", "MultiPolygon", "LineString", "MultiLineString"].includes(entity.__hyFeature.geometry.type)) {
addSelectedFeatureOverlay(entity.__hyFeature, {
townName: entity.__hyTownName || entity.__hyFeature?.properties?.name,
mode: "feature",
})
}
viewer?.scene?.requestRender?.()
}
function clearSelectedFeature() {
if (selectedEntity?.point && selectedEntity.__hyOriginalPointSize) {
selectedEntity.point.pixelSize = selectedEntity.__hyOriginalPointSize
}
selectedEntity = null
removeEntities(selectedOverlayEntities)
clearSelectedOverlayDataSource()
viewer?.scene?.requestRender?.()
}
function addSelectedFeatureOverlay(feature, options = {}) {
if (!viewer || !feature?.geometry) return []
const dataSource = createSelectedOverlayDataSource({ append: options.append === true })
if (!dataSource) return []
const Cesium = window.Cesium
const isTown = options.mode === "town"
const isWhite = options.highlightTone === "white" || options.mode === "white"
const fillColor = isTown ? "#30DCFF" : isWhite ? "#FFFFFF" : "#FF2D2D"
const strokeColor = isTown ? "#F2FFFF" : isWhite ? "#FFFFFF" : "#FF2D2D"
const style = {
fill: Cesium.Color.fromCssColorString(fillColor)
.withAlpha(options.fillAlpha ?? (isTown ? 0.16 : isWhite ? 0.035 : 0.16)),
stroke: Cesium.Color.fromCssColorString(strokeColor)
.withAlpha(options.strokeAlpha ?? (isWhite ? 0.9 : 0.98)),
lineWidth: options.lineWidth ?? (isTown ? 4.2 : isWhite ? 3.8 : 5.2),
pointSize: isTown ? 0 : options.pointSize ?? 14,
}
const entities = addSelectedOverlayGeometryEntities(feature.geometry, feature, dataSource.entities, style)
entities.forEach((entity) => {
entity.__hyFeature = null
entity.__hyLayer = null
entity.__hyTownName = options.townName || feature.properties?.name || ""
entity.__hySelectedOverlay = true
})
selectedOverlayEntities.push(...entities)
raiseSelectedOverlayDataSource(dataSource)
return entities
}
function createSelectedOverlayDataSource(options = {}) {
if (!viewer || !window.Cesium) return null
if (options.append && selectedOverlayDataSource) return selectedOverlayDataSource
clearSelectedOverlayDataSource()
selectedOverlayDataSource = new window.Cesium.CustomDataSource("selected-feature-overlay")
viewer.dataSources.add(selectedOverlayDataSource)
raiseSelectedOverlayDataSource(selectedOverlayDataSource)
return selectedOverlayDataSource
}
function clearSelectedOverlayDataSource() {
if (!viewer?.dataSources) {
selectedOverlayDataSource = null
return
}
const existingSources = Array.from(new Set([
selectedOverlayDataSource,
...(viewer.dataSources.getByName?.("selected-feature-overlay") || []),
].filter(Boolean)))
existingSources.forEach((dataSource) => {
try {
viewer.dataSources.remove(dataSource, true)
} catch (error) {
console.warn("[cesium-map] remove selected overlay failed", error)
}
})
selectedOverlayDataSource = null
selectedOverlayEntities = []
}
function raiseSelectedOverlayDataSource(dataSource) {
if (!viewer?.dataSources || !dataSource) return
try {
if (viewer.dataSources.raiseToTop) {
viewer.dataSources.raiseToTop(dataSource)
return
}
while (viewer.dataSources.indexOf?.(dataSource) < viewer.dataSources.length - 1) {
viewer.dataSources.raise(dataSource)
}
} catch (error) {
console.warn("[cesium-map] raise selected overlay failed", error)
}
}
function addSelectedOverlayGeometryEntities(geometry, feature, collection, style) {
const Cesium = window.Cesium
const created = []
if (!geometry || !collection) return created
const add = (options) => {
const entity = collection.add(options)
entity.__hyFeature = feature
created.push(entity)
return entity
}
if (geometry.type === "Point") {
const point = normalizeLngLat(geometry.coordinates)
if (!point || style.pointSize <= 0) return created
add({
position: Cesium.Cartesian3.fromDegrees(point[0], point[1]),
point: {
show: true,
color: style.fill,
pixelSize: style.pointSize,
outlineColor: style.stroke,
outlineWidth: 3,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
})
return created
}
if (geometry.type === "MultiPoint") {
geometry.coordinates.forEach((coordinates) => {
created.push(...addSelectedOverlayGeometryEntities({ type: "Point", coordinates }, feature, collection, style))
})
return created
}
if (geometry.type === "LineString") {
addSelectedOverlayLine(geometry.coordinates, feature, collection, style, created)
return created
}
if (geometry.type === "MultiLineString") {
geometry.coordinates.forEach((coordinates) => {
addSelectedOverlayLine(coordinates, feature, collection, style, created)
})
return created
}
if (geometry.type === "Polygon") {
addSelectedOverlayPolygon(geometry.coordinates, feature, collection, style, created)
return created
}
if (geometry.type === "MultiPolygon") {
geometry.coordinates.forEach((coordinates) => {
addSelectedOverlayPolygon(coordinates, feature, collection, style, created)
})
}
return created
}
function addSelectedOverlayPolygon(rings, feature, collection, style, created) {
const Cesium = window.Cesium
const hierarchy = createPolygonHierarchy(rings)
if (!hierarchy) return
const polygonEntity = collection.add({
polygon: {
hierarchy,
height: 0,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
perPositionHeight: false,
clampToGround: true,
material: style.fill,
outline: false,
classificationType: Cesium.ClassificationType.BOTH,
zIndex: 999,
},
})
polygonEntity.__hyFeature = feature
created.push(polygonEntity)
rings.forEach((ring) => addSelectedOverlayLine(ring, feature, collection, style, created))
}
function addSelectedOverlayLine(coordinates, feature, collection, style, created) {
const Cesium = window.Cesium
const positions = toCartesianPositions(coordinates)
if (positions.length < 2) return
const entity = collection.add({
polyline: {
positions,
width: style.lineWidth,
material: style.stroke,
depthFailMaterial: style.stroke,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
clampToGround: true,
classificationType: Cesium.ClassificationType.BOTH,
zIndex: 1000,
},
})
entity.__hyFeature = feature
created.push(entity)
}
function highlightFeature(feature, options = {}) {
if (!viewer || !feature) return false
const properties = feature.properties || feature.feature?.properties || (typeof feature === "object" ? feature : {})
const geometry = normalizeGeometry(
feature.geometry
|| feature.feature?.geometry
|| properties.geometry
|| properties.geom
|| properties.wkt
|| feature
)
if (!geometry) return false
clearSelectedFeature()
const highlightedFeature = {
type: "Feature",
id: feature.id || feature.feature?.id || properties.id,
properties,
geometry,
}
addSelectedFeatureOverlay(highlightedFeature, { mode: "feature", ...options })
viewer?.scene?.requestRender?.()
return selectedOverlayEntities.length > 0
}
function findTownshipFeature(name) {
const normalized = normalizeTownName(name)
if (!normalized) return null
return (hongyuanTownshipsGeoJson.features || []).find((feature) => normalizeTownName(feature?.properties?.name) === normalized) || null
}
function canSelectTownFromMap() {
return props.mapRanking?.scope === "town" && Array.isArray(props.mapRanking?.rows) && props.mapRanking.rows.length > 0
}
function canSelectTownName(name) {
if (!canSelectTownFromMap()) return false
const normalized = normalizeTownName(name)
return (props.mapRanking?.rows || []).some((row) => normalizeTownName(row.name) === normalized)
}
function findSelectableTownshipFeatureByLngLat(lngLat) {
if (!canSelectTownFromMap()) return null
return (hongyuanTownshipsGeoJson.features || []).find((feature) => {
const name = feature?.properties?.name
return canSelectTownName(name) && isLngLatInGeometry(lngLat, normalizeGeometry(feature?.geometry))
}) || null
}
function selectTownByName(name, options = {}) {
if (!canSelectTownFromMap()) return false
if (!canSelectTownName(name)) return false
const feature = findTownshipFeature(name)
if (!feature) return false
clearSelectedFeature()
addSelectedFeatureOverlay(feature, { mode: "town", townName: feature.properties?.name || name })
if (options.emitFeatureInfo !== false) emit("feature-info", { feature: null })
if (options.emitStatus !== false) {
emit("layer-status", {
type: "success",
text: `已选中${feature.properties?.name || name}`,
})
}
viewer?.scene?.requestRender?.()
return true
}
function focusTown(name, options = {}) {
const town = hongyuanTownshipLabelPoints.find((item) => normalizeTownName(item.name) === normalizeTownName(name))
const point = normalizeLngLat(town?.center)
if (!point) return null
if (options.select) selectTownByName(name, { emitFeatureInfo: false, emitStatus: false })
flyToPoint(point, 42000)
return { screen: getLngLatScreen(point) }
}
function focusProject(payload = {}) {
const query = normalizeProjectFocusPayload(payload)
const targets = findTopicEntities((feature) => matchesProjectFocusPayload(feature, query))
if (!targets.length) return null
const features = uniqueEntityFeatures(targets)
const highlightFeatures = getProjectHighlightFeatures(features)
clearSelectedFeature()
selectedEntity = targets[0]
if (selectedEntity?.point) {
selectedEntity.__hyOriginalPointSize = selectedEntity.point.pixelSize
selectedEntity.point.pixelSize = 18
}
highlightFeatures.forEach((feature, index) => {
addSelectedFeatureOverlay(feature, {
mode: "feature",
highlightTone: query.highlightTone,
append: index > 0,
})
})
const bounds = getFeaturesBounds(features)
if (bounds) {
flyToBounds(bounds, 0.9)
} else {
viewer.flyTo(targets[0], { duration: 0.9, offset: createFocusOffset(18000) })
}
viewer?.scene?.requestRender?.()
return {
entity: targets[0],
entities: targets,
feature: features[0] || targets[0].__hyFeature,
features,
highlightFeatures,
screen: getVectorFeaturesAnchorScreen(highlightFeatures.length ? highlightFeatures : features),
}
}
function focusYakIndustryPoint(payload = {}) {
const point = normalizeRowPoint(payload)
let target = findTopicEntity((feature) => matchesFeaturePayload(feature, payload))
if (target) {
selectEntity(target)
viewer.flyTo(target, { duration: 0.9, offset: createFocusOffset(16000) })
} else if (point) {
flyToPoint(point, 16000)
}
return {
feature: target?.__hyFeature || null,
screen: getYakIndustryPointScreen(payload),
}
}
function focusVectorFeature(payload = {}) {
const target = findTopicEntity((feature) => matchesFeaturePayload(feature, payload))
if (!target) return null
selectEntity(target)
viewer.flyTo(target, { duration: 0.9, offset: createFocusOffset(18000) })
return {
feature: target.__hyFeature,
screen: getVectorFeatureScreen(target.__hyFeature),
}
}
function getVectorFeatureScreen(feature) {
const center = getGeometryCenter(normalizeGeometry(feature?.geometry || feature?.feature?.geometry))
return center ? getLngLatScreen(center) : null
}
function getVectorFeatureAnchorScreen(feature) {
return getVectorFeaturesAnchorScreen([feature]) || getVectorFeatureScreen(feature)
}
function getVectorFeaturesAnchorScreen(features = []) {
const screens = []
;(Array.isArray(features) ? features : [features]).forEach((feature) => {
const properties = feature?.properties || feature?.feature?.properties || {}
const geometry = normalizeGeometry(
feature?.geometry
|| feature?.feature?.geometry
|| properties.geometry
|| properties.geom
|| properties.wkt
|| properties.geo
|| properties.the_geom
|| properties.geometryWkt
|| properties.geometry_wkt
|| properties.shape
)
if (!geometry) {
const fallback = getVectorFeatureScreen(feature)
if (fallback) screens.push(fallback)
return
}
const points = []
collectCoordinates(geometry.coordinates, points)
screens.push(...points.map(getLngLatScreen).filter(Boolean))
})
if (!screens.length) return null
const xs = screens.map((point) => Number(point.x)).filter(Number.isFinite)
const ys = screens.map((point) => Number(point.y)).filter(Number.isFinite)
if (!xs.length || !ys.length) return null
return {
x: (Math.min(...xs) + Math.max(...xs)) / 2,
y: Math.min(...ys),
}
}
function getYakIndustryPointScreen(payload = {}) {
const point = normalizeRowPoint(payload)
if (point) return getLngLatScreen(point)
const target = findTopicEntity((feature) => matchesFeaturePayload(feature, payload))
const position = target?.position?.getValue?.(window.Cesium.JulianDate.now())
return position ? toLogicalScreen(window.Cesium.SceneTransforms.wgs84ToWindowCoordinates(viewer.scene, position)) : null
}
function focusPasture(payload = {}) {
clearPastureFocus()
const grasslands = normalizePastureGrasslands(payload.grasslands || payload)
const whiteTone = payload.highlightTone === "white"
const glowStyle = whiteTone
? null
: {
fillColor: "#FF3B30",
strokeColor: "#FF3B30",
fillAlpha: 0.1,
strokeAlpha: 0.68,
lineWidth: 12,
}
const focusStyle = whiteTone
? {
fillColor: "#F7FFFF",
strokeColor: "#F7FFFF",
fillAlpha: 0.025,
strokeAlpha: 0.9,
lineWidth: 3.8,
}
: {
fillColor: "#FF2D2D",
strokeColor: "#FFE1E1",
fillAlpha: 0.3,
strokeAlpha: 1,
lineWidth: 6.2,
}
if (!payload.locateOnly) {
grasslands.forEach((feature) => {
if (glowStyle) addGeoFeature(feature, { key: "pasture-focus-glow", name: payload.name || "牧场高亮" }, focusEntities, glowStyle)
addGeoFeature(feature, { key: "pasture-focus", name: payload.name || "牧场高亮" }, focusEntities, focusStyle)
})
;(Array.isArray(payload.yakMarks) ? payload.yakMarks : []).forEach((row, index) => {
const point = normalizeRowPoint(row)
if (!point) return
addGeoFeature({
type: "Feature",
id: row.id || index,
properties: { ...row },
geometry: { type: "Point", coordinates: point },
}, { key: "pasture-focus-yak", name: "牦牛识别" }, focusEntities, {
fillColor: "#FFCB45",
strokeColor: "#FFF4B8",
pointSize: 11,
})
})
}
const bounds = getFeaturesBounds(grasslands)
if (bounds) flyToBounds(bounds, 0.9)
viewer?.scene?.requestRender?.()
return Boolean(grasslands.length)
}
function clearPastureFocus() {
removeEntities(focusEntities)
viewer?.scene?.requestRender?.()
}
function clearFloatingOverlays() {
clearSelectedFeature()
}
function setBoundaryDrawing(enabled) {
drawingEnabled = Boolean(enabled)
if (!drawingEnabled) drawingPoints = []
updateDrawingEntities()
emit("draw-boundary", { status: drawingEnabled ? "drawing" : "clear", pointCount: drawingPoints.length, points: drawingPoints })
}
function addDrawingPoint(lngLat) {
drawingPoints.push([lngLat.lng, lngLat.lat])
updateDrawingEntities()
emit("draw-boundary", { status: "drawing", pointCount: drawingPoints.length, points: drawingPoints })
}
function undoBoundaryPoint() {
drawingPoints.pop()
updateDrawingEntities()
emit("draw-boundary", { status: "drawing", pointCount: drawingPoints.length, points: drawingPoints })
}
function finishBoundaryDrawing() {
if (drawingPoints.length < 3) return null
const cameraSnapshot = captureCameraView()
const ring = [...drawingPoints, drawingPoints[0]]
const feature = {
type: "Feature",
properties: { source: "drawn" },
geometry: { type: "Polygon", coordinates: [ring] },
}
drawingEnabled = false
drawingPoints = []
removeEntities(drawingEntities)
addGeoFeature(feature, { key: "analysis-boundary", name: "分析边界" }, drawingEntities, {
fillColor: "#24F6C4",
strokeColor: "#B7FFF2",
fillAlpha: 0.12,
strokeAlpha: 1,
lineWidth: 3,
})
emit("draw-boundary", { status: "complete", pointCount: 0, points: [], feature })
restoreCameraView(cameraSnapshot)
scheduleCameraRestore(cameraSnapshot)
return feature
}
function clearAnalysisBoundary() {
drawingEnabled = false
drawingPoints = []
removeEntities(drawingEntities)
removeEntities(analysisEntities)
emit("draw-boundary", { status: "clear", pointCount: 0, points: [] })
viewer?.scene?.requestRender?.()
}
function updateDrawingEntities() {
if (!viewer) return
const Cesium = window.Cesium
removeEntities(drawingEntities)
drawingPoints.forEach((point) => {
drawingEntities.push(viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(point[0], point[1], 12),
point: {
pixelSize: 10,
color: Cesium.Color.fromCssColorString("#24F6C4"),
outlineColor: Cesium.Color.WHITE,
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}))
})
if (drawingPoints.length > 1) {
drawingEntities.push(viewer.entities.add({
polyline: {
positions: toCartesianPositions(drawingPoints),
width: 3,
material: Cesium.Color.fromCssColorString("#24F6C4"),
clampToGround: true,
},
}))
}
viewer.scene.requestRender()
}
function showAnalysisResult(payload = {}) {
const boundary = payload.boundary?.type === "Feature" ? payload.boundary : payload.boundary?.geometry ? payload.boundary : null
const preserveView = payload.preserveView === true || boundary?.properties?.source === "drawn"
const cameraSnapshot = preserveView ? captureCameraView() : null
if (preserveView) viewer?.camera?.cancelFlight?.()
removeEntities(analysisEntities)
if (boundary) addGeoFeature(boundary, { key: "analysis-result-boundary" }, analysisEntities, {
fillColor: "#24F6C4",
strokeColor: "#B7FFF2",
fillAlpha: 0.1,
lineWidth: 3,
})
;(Array.isArray(payload.matches) ? payload.matches : []).forEach((feature) => {
const normalized = feature?.type === "Feature" ? feature : feature?.feature || null
if (normalized) addGeoFeature(normalized, { key: "analysis-result-feature" }, analysisEntities, {
fillColor: "#30DCFF",
strokeColor: "#C9FBFF",
fillAlpha: 0.1,
lineWidth: 2.6,
})
})
if (!preserveView) {
const features = [boundary, ...(payload.matches || [])].filter(Boolean)
const bounds = getFeaturesBounds(features)
if (bounds) flyToBounds(bounds, 0.9)
} else {
restoreCameraView(cameraSnapshot)
scheduleCameraRestore(cameraSnapshot)
}
viewer?.scene?.requestRender?.()
}
function flyToPoint(point, altitude = 22000, duration = 0.9) {
if (!viewer || !point) return
const Cesium = window.Cesium
const target = Cesium.Cartesian3.fromDegrees(point[0], point[1], 3600)
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 1), {
offset: createFocusOffset(altitude),
duration,
})
}
function flyToBounds(bounds, duration = 0.9) {
if (!viewer || !bounds) return
const Cesium = window.Cesium
const center = [
(Number(bounds.west) + Number(bounds.east)) / 2,
(Number(bounds.south) + Number(bounds.north)) / 2,
]
if (!center.every(Number.isFinite)) return
const span = Math.max(
Math.abs(Number(bounds.east) - Number(bounds.west)),
Math.abs(Number(bounds.north) - Number(bounds.south)),
)
const range = Math.max(18000, Math.min(160000, span * 135000))
const target = Cesium.Cartesian3.fromDegrees(center[0], center[1], 2600)
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 1), {
offset: createFocusOffset(range),
duration,
})
}
function applyLayerMapView(layer) {
if (!viewer || !layer) return
const view = layer.mapView || (layer.cameraPosition ? { cameraPosition: layer.cameraPosition } : null)
if (!view) return
const Cesium = window.Cesium
const duration = Number.isFinite(Number(view.duration)) ? Number(view.duration) : 0.9
const targetLngLat = normalizeLngLat(view.targetLngLat)
if (targetLngLat) {
const baseRange = zoomToAltitude(props.initialZoom)
const distanceScale = Number.isFinite(Number(view.distanceScale)) ? Number(view.distanceScale) : 1
const range = Math.max(18000, Math.min(560000, baseRange * distanceScale))
const targetHeight = Number.isFinite(Number(view.targetHeight))
? Number(view.targetHeight)
: 2600
const heading = Number.isFinite(Number(view.heading)) ? Number(view.heading) : DETAIL_VIEW_HEADING
const pitch = Number.isFinite(Number(view.pitch)) ? Number(view.pitch) : DETAIL_VIEW_PITCH
const target = Cesium.Cartesian3.fromDegrees(targetLngLat[0], targetLngLat[1], targetHeight)
viewer.camera.flyToBoundingSphere(new Cesium.BoundingSphere(target, 1), {
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(heading),
Cesium.Math.toRadians(pitch),
range,
),
duration,
})
return
}
if (view.bounds) flyToBounds(view.bounds, duration)
}
function createFocusOffset(range) {
const Cesium = window.Cesium
return new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(DETAIL_VIEW_HEADING),
Cesium.Math.toRadians(DETAIL_VIEW_PITCH),
range,
)
}
function getLngLatScreen(point) {
if (!viewer || !point) return null
const Cesium = window.Cesium
const cartesian = Cesium.Cartesian3.fromDegrees(point[0], point[1], 16)
return toViewportScreen(Cesium.SceneTransforms.wgs84ToWindowCoordinates(viewer.scene, cartesian))
}
function findTopicEntity(predicate) {
return [...topicEntities, ...focusEntities].find((entity) => entity.__hyFeature && predicate(entity.__hyFeature)) || null
}
function findTopicEntities(predicate) {
return [...topicEntities, ...focusEntities].filter((entity) => entity.__hyFeature && predicate(entity.__hyFeature))
}
function findTopicEntityAtLngLat(lngLat, screen) {
if (!Number.isFinite(lngLat?.lng) || !Number.isFinite(lngLat?.lat)) return null
const entities = uniqueFeatureEntities(topicEntities)
const containing = entities
.map((entity) => ({
entity,
geometry: normalizeGeometry(entity.__hyFeature?.geometry),
}))
.filter((item) => item.geometry && isLngLatInGeometry(lngLat, item.geometry))
.sort((a, b) => getGeometryAreaScore(a.geometry) - getGeometryAreaScore(b.geometry))
if (containing.length) return containing[0].entity
const nearest = entities
.map((entity) => ({
entity,
distance: getGeometryScreenDistance(normalizeGeometry(entity.__hyFeature?.geometry), screen),
}))
.filter((item) => Number.isFinite(item.distance))
.sort((a, b) => a.distance - b.distance)[0]
return nearest?.distance <= 18 ? nearest.entity : null
}
function matchesFeaturePayload(feature, payload = {}) {
const properties = feature?.properties || {}
const ids = [payload.id, payload.key, payload.featureId, payload.projectId].filter((value) => value !== undefined && value !== null && value !== "").map(String)
const featureIds = [feature.id, properties.id, properties.key, properties.feature_id, properties.project_id].filter((value) => value !== undefined && value !== null && value !== "").map(String)
if (ids.some((id) => featureIds.includes(id))) return true
const names = [payload.name, payload.fullName, payload.projectName].filter(Boolean).map(String)
const featureNames = [properties.name, properties.fullName, properties.project_name, properties.projectName].filter(Boolean).map(String)
if (names.some((name) => featureNames.includes(name))) return true
const point = normalizeRowPoint(payload)
const featurePoint = normalizeRowPoint({ ...properties, geometry: feature.geometry })
return Boolean(point && featurePoint && Math.abs(point[0] - featurePoint[0]) < 0.00002 && Math.abs(point[1] - featurePoint[1]) < 0.00002)
}
function normalizeProjectFocusPayload(payload = {}) {
if (payload && typeof payload === "object") return payload
return {
name: payload,
projectName: payload,
project_name: payload,
}
}
function matchesProjectFocusPayload(feature, payload = {}) {
if (matchesFeaturePayload(feature, payload)) return true
const properties = feature?.properties || {}
const ids = collectMatchTexts([payload.id, payload.key, payload.featureId, payload.projectId, payload.project_id])
const featureIds = collectMatchTexts([feature?.id, properties.id, properties.key, properties.feature_id, properties.project_id, properties.gid])
if (ids.some((id) => featureIds.includes(id))) return true
const names = collectMatchTexts([payload.name, payload.fullName, payload.projectName, payload.project_name, payload.title])
const featureNames = collectMatchTexts([properties.name, properties.fullName, properties.project_name, properties.projectName, properties.title])
return names.some((name) => featureNames.includes(name))
}
function collectMatchTexts(values = []) {
const texts = []
values.forEach((value) => {
if (value === undefined || value === null || value === "") return
const text = String(value).trim()
if (!text) return
texts.push(text)
const compact = text.replace(/\s+/g, "")
if (compact && compact !== text) texts.push(compact)
})
return Array.from(new Set(texts))
}
function uniqueFeatureEntities(entities = []) {
const seen = new Set()
return entities.filter((entity) => {
const feature = entity?.__hyFeature
if (!feature || seen.has(feature)) return false
seen.add(feature)
return true
})
}
function uniqueEntityFeatures(entities = []) {
return uniqueFeatureEntities(entities).map((entity) => entity.__hyFeature).filter(Boolean)
}
function getProjectHighlightFeatures(features = []) {
const drawableFeatures = features.filter((feature) => {
const type = String(normalizeGeometry(feature?.geometry)?.type || "")
return type.includes("Polygon") || type.includes("LineString")
})
const rangeFeatures = drawableFeatures.filter((feature) => feature?.properties?.feature_role === "工程范围")
return rangeFeatures.length ? rangeFeatures : drawableFeatures
}
function normalizePastureGrasslands(value) {
const rows = Array.isArray(value)
? value
: value?.grasslands || value?.features || value?.rows || value?.list || (value ? [value] : [])
return rows.map((row, index) => {
if (row?.type === "Feature") return row
const geometry = normalizeGeometry(row?.geometry) || parseWktGeometry(pickWkt(row))
if (!geometry) return null
return {
type: "Feature",
id: row?.id ?? row?.grasslandId ?? index,
properties: { ...row },
geometry,
}
}).filter(Boolean)
}
function normalizeGeometry(value) {
if (!value) return null
if (value.type === "Feature") return normalizeGeometry(value.geometry)
if (value.type && Array.isArray(value.coordinates)) return value
if (typeof value === "string") {
const text = value.trim()
if (!text) return null
if (text.startsWith("{") || text.startsWith("[")) {
try {
return normalizeGeometry(JSON.parse(text))
} catch (error) {
return null
}
}
return parseWktGeometry(text)
}
return null
}
function parseWktGeometry(value) {
const text = String(value || "").trim().replace(/^SRID=\d+;/i, "")
const match = text.match(/^([A-Z]+)(?:\s+[A-Z]+)?\s*(\(.+\))$/i)
if (!match) return null
const type = match[1].toUpperCase()
const body = match[2]
if (type === "POINT") return { type: "Point", coordinates: parseWktPoint(body) }
if (type === "MULTIPOINT") return { type: "MultiPoint", coordinates: getWktChildGroups(body).map(parseWktPoint).filter(Boolean) }
if (type === "LINESTRING") return { type: "LineString", coordinates: parseWktLine(body) }
if (type === "MULTILINESTRING") return { type: "MultiLineString", coordinates: getWktChildGroups(body).map(parseWktLine).filter((line) => line.length) }
if (type === "POLYGON") return { type: "Polygon", coordinates: parseWktPolygon(body) }
if (type === "MULTIPOLYGON") return { type: "MultiPolygon", coordinates: getWktChildGroups(body).map(parseWktPolygon).filter((polygon) => polygon.length) }
return null
}
function parseWktPoint(value) {
const parts = String(value || "").replace(/^\(+|\)+$/g, "").trim().split(/\s+/).slice(0, 2).map(Number)
return parts.length === 2 && parts.every(Number.isFinite) ? parts : null
}
function parseWktLine(value) {
return String(value || "").replace(/^\(+|\)+$/g, "").split(",").map(parseWktPoint).filter(Boolean)
}
function parseWktPolygon(value) {
return getWktChildGroups(value).map(parseWktLine).filter((ring) => ring.length >= 3)
}
function getWktChildGroups(value) {
const trimmed = String(value || "").trim()
const text = trimmed.startsWith("(") && trimmed.endsWith(")") ? trimmed.slice(1, -1) : trimmed
const groups = []
let depth = 0
let start = -1
Array.from(text).forEach((char, index) => {
if (char === "(") {
if (depth === 0) start = index
depth += 1
} else if (char === ")") {
depth -= 1
if (depth === 0 && start >= 0) {
groups.push(text.slice(start, index + 1))
start = -1
}
}
})
return groups.length ? groups : [text]
}
function toCartesianPositions(coordinates = []) {
const Cesium = window.Cesium
return coordinates.map(normalizeLngLat).filter(Boolean).map((point) => Cesium.Cartesian3.fromDegrees(point[0], point[1], 8))
}
function toRaisedCartesianPositions(coordinates = [], height = COUNTY_BOUNDARY_RENDER_HEIGHT) {
const Cesium = window.Cesium
return coordinates
.map(normalizeLngLat)
.filter(Boolean)
.map((point) => Cesium.Cartesian3.fromDegrees(point[0], point[1], height))
}
function normalizeLngLat(value) {
if (!Array.isArray(value) || value.length < 2) return null
const lng = Number(value[0])
const lat = Number(value[1])
return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : null
}
function normalizeRowPoint(row = {}) {
const geometry = normalizeGeometry(row.geometry)
if (geometry?.type === "Point") return normalizeLngLat(geometry.coordinates)
const candidates = [
[row.longitude, row.latitude],
[row.lng, row.lat],
[row.lon, row.lat],
[row.x, row.y],
row.point,
row.center,
row.centroid,
row.coordinates,
]
return candidates.map(normalizeLngLat).find(Boolean) || null
}
function isLngLatInGeometry(lngLat, geometry) {
if (!lngLat || !geometry?.coordinates) return false
if (geometry.type === "Polygon") return isLngLatInPolygon(lngLat, geometry.coordinates)
if (geometry.type === "MultiPolygon") {
return geometry.coordinates.some((polygon) => isLngLatInPolygon(lngLat, polygon))
}
return false
}
function getGeometryAreaScore(geometry) {
if (!geometry?.coordinates) return Number.POSITIVE_INFINITY
if (geometry.type === "Polygon") return getLngLatPolygonAreaScore(geometry.coordinates)
if (geometry.type === "MultiPolygon") {
return geometry.coordinates.reduce((sum, polygon) => sum + getLngLatPolygonAreaScore(polygon), 0)
}
return Number.POSITIVE_INFINITY
}
function getLngLatPolygonAreaScore(rings = []) {
const outer = rings[0] || []
if (outer.length < 3) return Number.POSITIVE_INFINITY
let area = 0
for (let index = 0; index < outer.length; index += 1) {
const current = outer[index]
const next = outer[(index + 1) % outer.length]
area += Number(current?.[0] || 0) * Number(next?.[1] || 0) - Number(next?.[0] || 0) * Number(current?.[1] || 0)
}
return Math.abs(area)
}
function getGeometryScreenDistance(geometry, screen) {
if (!geometry?.coordinates || !screen) return Number.POSITIVE_INFINITY
if (geometry.type === "Point") return getPointScreenDistance(geometry.coordinates, screen)
if (geometry.type === "MultiPoint") {
return Math.min(...geometry.coordinates.map((point) => getPointScreenDistance(point, screen)))
}
if (geometry.type === "LineString") return getLineScreenDistance(geometry.coordinates, screen)
if (geometry.type === "MultiLineString") {
return Math.min(...geometry.coordinates.map((line) => getLineScreenDistance(line, screen)))
}
return Number.POSITIVE_INFINITY
}
function getPointScreenDistance(coordinate, screen) {
const point = getLngLatScreen(normalizeLngLat(coordinate))
if (!point) return Number.POSITIVE_INFINITY
return Math.hypot(Number(point.x) - Number(screen.x), Number(point.y) - Number(screen.y))
}
function getLineScreenDistance(line = [], screen) {
if (!Array.isArray(line) || line.length < 2) return Number.POSITIVE_INFINITY
let distance = Number.POSITIVE_INFINITY
for (let index = 1; index < line.length; index += 1) {
const first = getLngLatScreen(normalizeLngLat(line[index - 1]))
const second = getLngLatScreen(normalizeLngLat(line[index]))
if (!first || !second) continue
distance = Math.min(distance, getScreenSegmentDistance(screen, first, second))
}
return distance
}
function getScreenSegmentDistance(point, first, second) {
const x = Number(first.x)
const y = Number(first.y)
const dx = Number(second.x) - x
const dy = Number(second.y) - y
if (![x, y, dx, dy].every(Number.isFinite)) return Number.POSITIVE_INFINITY
if (!dx && !dy) return Math.hypot(Number(point.x) - x, Number(point.y) - y)
const t = Math.max(0, Math.min(1, ((Number(point.x) - x) * dx + (Number(point.y) - y) * dy) / (dx * dx + dy * dy)))
return Math.hypot(Number(point.x) - (x + t * dx), Number(point.y) - (y + t * dy))
}
function isLngLatInPolygon(lngLat, rings = []) {
if (!rings.length) return false
if (!isLngLatInRing(lngLat, rings[0])) return false
return !rings.slice(1).some((ring) => isLngLatInRing(lngLat, ring))
}
function isLngLatInRing(lngLat, ring = []) {
if (ring.length < 3) return false
const x = Number(lngLat.lng)
const y = Number(lngLat.lat)
if (!Number.isFinite(x) || !Number.isFinite(y)) return false
let inside = false
for (let currentIndex = 0, previousIndex = ring.length - 1; currentIndex < ring.length; previousIndex = currentIndex++) {
const current = ring[currentIndex]
const previous = ring[previousIndex]
const xi = Number(current?.[0])
const yi = Number(current?.[1])
const xj = Number(previous?.[0])
const yj = Number(previous?.[1])
if (![xi, yi, xj, yj].every(Number.isFinite)) continue
if (isPointOnSegment(x, y, xi, yi, xj, yj)) return true
const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
if (intersects) inside = !inside
}
return inside
}
function isPointOnSegment(px, py, x1, y1, x2, y2) {
const cross = (px - x1) * (y2 - y1) - (py - y1) * (x2 - x1)
if (Math.abs(cross) > 1e-10) return false
const dot = (px - x1) * (px - x2) + (py - y1) * (py - y2)
return dot <= 1e-10
}
function getGeometryCenter(geometry) {
if (!geometry) return null
if (geometry.type === "Point") return normalizeLngLat(geometry.coordinates)
const points = []
collectCoordinates(geometry.coordinates, points)
if (!points.length) return null
return [
points.reduce((sum, point) => sum + point[0], 0) / points.length,
points.reduce((sum, point) => sum + point[1], 0) / points.length,
]
}
function collectCoordinates(value, target) {
const point = normalizeLngLat(value)
if (point && !Array.isArray(value[0])) {
target.push(point)
return
}
if (Array.isArray(value)) value.forEach((item) => collectCoordinates(item, target))
}
function getFeaturesBounds(features = []) {
const points = []
features.forEach((feature) => {
const geometry = normalizeGeometry(feature?.geometry || feature?.feature?.geometry || feature)
if (geometry) collectCoordinates(geometry.coordinates, points)
})
if (!points.length) return null
const lngs = points.map((point) => point[0])
const lats = points.map((point) => point[1])
const paddingLng = Math.max(0.006, (Math.max(...lngs) - Math.min(...lngs)) * 0.18)
const paddingLat = Math.max(0.006, (Math.max(...lats) - Math.min(...lats)) * 0.18)
return {
west: Math.max(HONGYUAN_BOUNDS.west, Math.min(...lngs) - paddingLng),
south: Math.max(HONGYUAN_BOUNDS.south, Math.min(...lats) - paddingLat),
east: Math.min(HONGYUAN_BOUNDS.east, Math.max(...lngs) + paddingLng),
north: Math.min(HONGYUAN_BOUNDS.north, Math.max(...lats) + paddingLat),
}
}
function removeEntities(target) {
if (!viewer) {
target.length = 0
return
}
target.splice(0).forEach((entity) => viewer.entities.remove(entity))
}
function clearTopicLayers() {
topicImageryLayers.splice(0).forEach((layer) => viewer?.imageryLayers?.remove?.(layer, true))
removeEntities(topicEntities)
clearSelectedFeature()
}
function destroyMap() {
layerRevision += 1
resizeObserver?.disconnect?.()
resizeObserver = null
cameraMoveEndRemove?.()
cameraMoveEndRemove = null
clickHandler?.destroy?.()
clickHandler = null
clearTopicLayers()
removeEntities(countyStageEntities)
removeEntities(countyFrameEntities)
removeEntities(boundaryEntities)
removeEntities(rankingEntities)
removeEntities(focusEntities)
removeEntities(drawingEntities)
removeEntities(analysisEntities)
if (viewer && !viewer.isDestroyed?.()) viewer.destroy()
viewer = null
baseImageryLayer = null
nationalBackdropImageryLayer = null
canvasMap.value = null
if (viewerRef.value) viewerRef.value.replaceChildren()
}
function clampOpacity(value, fallback = 1) {
const number = Number(value)
return Number.isFinite(number) ? Math.max(0, Math.min(1, number)) : fallback
}
function normalizeTownName(value) {
return String(value || "").trim().replace(/(镇|乡)$/, "")
}
function formatMapValue(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "--"
return Number.isInteger(number) ? String(number) : String(Number(number.toFixed(2)))
}
defineExpose({
loadMap,
hasMap,
play,
resume,
pause,
refreshView,
resetView,
destroyMap,
highlightFeature,
clearSelectedFeature,
clearFloatingOverlays,
focusProject,
focusPasture,
clearPastureFocus,
focusTown,
focusYakIndustryPoint,
focusVectorFeature,
getVectorFeatureAnchorScreen,
getVectorFeaturesAnchorScreen,
getVectorFeatureScreen,
getYakIndustryPointScreen,
setBoundaryDrawing,
finishBoundaryDrawing,
undoBoundaryPoint,
clearAnalysisBoundary,
showAnalysisResult,
canvasMap,
})
</script>
<style scoped lang="scss">
.cesium-map {
position: absolute;
z-index: 1;
inset: 0;
overflow: hidden;
background: #020b12;
}
.cesium-map__viewer,
.cesium-map__viewer :deep(.cesium-viewer),
.cesium-map__viewer :deep(.cesium-viewer-cesiumWidgetContainer),
.cesium-map__viewer :deep(.cesium-widget),
.cesium-map__viewer :deep(canvas) {
width: 100%;
height: 100%;
}
.cesium-map__viewer :deep(canvas) {
display: block;
outline: none;
}
.cesium-map__viewer :deep(.cesium-viewer-bottom),
.cesium-map__viewer :deep(.cesium-widget-credits) {
display: none !important;
}
</style>