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.
 
 
 
 
 
 

2357 lines
79 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, getPublicMapResourcePath } from "@/config/mapResources"
import { hongyuanTownshipLabelPoints, hongyuanTownshipsGeoJson } from "@/config/townshipBoundaries"
import hongyuanCountyOutlineRaw from "/public/assets/json/红原县-轮廓.json?raw"
const NATIONAL_BACKDROP_BOUNDS = {
west: 100.18,
south: 30.96,
east: 105.02,
north: 34.46,
}
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 HONGYUAN_CLIP_MULTIPOLYGON = parseClipMultiPolygon(hongyuanCountyOutlineRaw)
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://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png",
},
terrainUrl: {
type: String,
default: "/terrain",
},
center: {
type: Array,
default: () => [102.600005, 32.585593],
},
initialZoom: {
type: Number,
default: 9,
},
maxZoom: {
type: Number,
default: 21,
},
adminBoundaryLineScale: {
type: Number,
default: 1,
},
showNationalBackdrop: {
type: Boolean,
default: true,
},
})
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 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.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 referenceProvider =
normalizedReferenceTileUrlTemplate && normalizedReferenceTileUrlTemplate !== normalizedTileUrlTemplate
? createImageryProvider(Cesium, normalizedReferenceTileUrlTemplate, {
maximumLevel: Math.min(props.maxZoom, 18),
})
: null
const baseProvider = normalizedTileUrlTemplate
? createImageryProvider(Cesium, normalizedTileUrlTemplate, { maximumLevel: props.maxZoom })
: 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,
requestRenderMode: true,
maximumRenderTimeChange: Infinity,
contextOptions: {
webgl: {
alpha: true,
antialias: true,
},
},
})
if (referenceProvider) {
const referenceLayer = viewer.imageryLayers.addImageryProvider(referenceProvider)
referenceLayer.brightness = 0.92
referenceLayer.contrast = 1.08
referenceLayer.saturation = 1.04
referenceLayer.gamma = 0.98
}
if (baseProvider) {
baseImageryLayer = viewer.imageryLayers.addImageryProvider(baseProvider)
baseImageryLayer.alpha = isHongyuanHighResolutionTile(normalizedTileUrlTemplate) ? 0.82 : 1
baseImageryLayer.brightness = 0.9
baseImageryLayer.contrast = 1.1
baseImageryLayer.saturation = 1.05
baseImageryLayer.gamma = 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.scene.fxaa = true
viewer.scene.postProcessStages.fxaa.enabled = true
viewer.scene.screenSpaceCameraController.enableCollisionDetection = true
viewer.scene.screenSpaceCameraController.minimumZoomDistance = 350
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)
addCountyFrame()
addTownshipBoundaries()
bindMapClick()
connectResizeObserver()
connectBoundaryVisualScale()
setCountyView(false)
updateBoundaryVisualScale()
refreshTopicLayers()
refreshMapRanking()
const facade = {
viewer,
clearSelectedFeature,
focusProject,
focusPasture,
focusTownByName: focusTown,
focusYakIndustryPoint,
focusVectorFeature,
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,
tilingScheme: new Cesium.WebMercatorTilingScheme(),
credit: "",
})
}
function normalizeTileUrlTemplate(url) {
return String(url || "").trim()
}
function isHongyuanHighResolutionTile(url) {
return /hy-result|qkl-map/i.test(String(url || ""))
}
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 parseClipMultiPolygon(raw) {
try {
const data = typeof raw === "string" ? JSON.parse(raw) : raw
const geometry = data?.type === "FeatureCollection"
? data.features?.[0]?.geometry
: data?.type === "Feature"
? data.geometry
: data
if (geometry?.type === "Polygon") return [geometry.coordinates]
if (geometry?.type === "MultiPolygon") return geometry.coordinates
} catch (error) {
console.warn("[cesium-map] county clip boundary failed", error)
}
return []
}
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 resetView() {
clearSelectedFeature()
setCountyView(true)
}
function setCountyView(animated = true) {
if (!viewer) return
const Cesium = window.Cesium
const center = getCountyCenter()
const range = zoomToAltitude(props.initialZoom)
const target = Cesium.Cartesian3.fromDegrees(center[0], center[1] - COUNTY_VIEW_CENTER_LAT_OFFSET, 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 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() {
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.25 + nearWeight * 0.55,
countyOutline: 1.35 + nearWeight * 0.45,
countyHalo: 2.25 + nearWeight * 0.7,
}
boundaryEntities.forEach((entity) => {
if (entity?.polyline && entity.__hyBoundaryRole === "township") entity.polyline.width = widths.township
})
countyFrameEntities.forEach((entity) => {
if (!entity?.polyline) return
if (entity.__hyBoundaryRole === "county-outline") entity.polyline.width = widths.countyOutline
if (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 {
for (const layer of layers) {
if (revision !== layerRevision) return
await addTopicLayer(layer, revision)
}
if (revision !== layerRevision) return
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.92)
: clampOpacity(layer.opacity, 0.86)
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.92)
: clampOpacity(layer.opacity, 0.86)
imageryLayer.__hyLayer = layer
topicImageryLayers.push(imageryLayer)
}
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 : `/${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 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 && HONGYUAN_CLIP_MULTIPOLYGON.length) {
polygons = polygonClipping.intersection(polygons, HONGYUAN_CLIP_MULTIPOLYGON)
}
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,
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"
return {
fill: colorWithAlpha(Cesium, fillText, overrides.fillAlpha ?? Math.min(0.34, opacity * 0.34)),
stroke: colorWithAlpha(Cesium, strokeText, overrides.strokeAlpha ?? Math.max(0.72, opacity)),
lineWidth: overrides.lineWidth || Math.max(1.6, 1.8 * Number(props.adminBoundaryLineScale || 1)),
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.94),
fill: Cesium.Color.TRANSPARENT,
lineWidth: 1.65,
pointSize: 0,
}
;(hongyuanTownshipsGeoJson.features || []).forEach((feature) => {
const geometry = normalizeGeometry(feature.geometry)
if (!geometry) return
const entities = addGeometryEntities(geometry, feature, { key: "township-boundary", name: "乡镇边界", hidePointFeatures: true }, style)
entities.forEach((entity) => {
if (entity.polygon) viewer.entities.remove(entity)
if (entity.polyline) {
entity.__hyBoundaryRole = "township"
entity.__hyTownName = feature.properties?.name
}
})
boundaryEntities.push(...entities.filter((entity) => !entity.isDestroyed?.() && viewer.entities.contains(entity)))
})
viewer.scene.requestRender()
}
function addCountyFrame() {
if (!viewer || !HONGYUAN_CLIP_MULTIPOLYGON.length) return
removeEntities(countyFrameEntities)
const Cesium = window.Cesium
addCountyOutsideMask(Cesium)
HONGYUAN_CLIP_MULTIPOLYGON.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: toCartesianPositions(ring),
width: 2.8,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.18,
color: Cesium.Color.fromCssColorString("#00E7FF").withAlpha(0.24),
}),
depthFailMaterial: Cesium.Color.fromCssColorString("#00E7FF").withAlpha(0.16),
clampToGround: true,
},
})
haloEntity.__hyBoundaryRole = "county-halo"
const outlineEntity = viewer.entities.add({
polyline: {
positions: toCartesianPositions(ring),
width: 1.45,
material: Cesium.Color.fromCssColorString("#8FFFF7").withAlpha(0.9),
depthFailMaterial: Cesium.Color.fromCssColorString("#8FFFF7").withAlpha(0.9),
clampToGround: true,
},
})
outlineEntity.__hyBoundaryRole = "county-outline"
countyFrameEntities.push(haloEntity, outlineEntity)
})
viewer.scene.requestRender()
}
function addCountyOutsideMask(Cesium) {
const outerMask = createCountyOutsideMaskPolygon()
let maskPolygons = []
try {
maskPolygons = polygonClipping.difference([outerMask], HONGYUAN_CLIP_MULTIPOLYGON)
} 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.64),
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 = `v13-lightweight-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(3, Math.max(2.5, window.devicePixelRatio || 2))
const mainFont = "760 13px 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 = 5
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
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(126, 251, 246, 0.34)"
context.shadowBlur = 2
context.font = mainFont
context.lineWidth = 2
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(48, 220, 255, 0.5)"
context.shadowBlur = 5
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
if (showTownNames) {
hongyuanTownshipLabelPoints.forEach((town) => {
const row = rowByName.get(normalizeTownName(town.name))
const labelLines = [town.name]
if (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 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: Cesium.VerticalOrigin.BOTTOM,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
pixelOffset: 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 cartesian = pickGlobePosition(movement.position)
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 = toLogicalScreen(movement.position)
if (drawingEnabled) {
addDrawingPoint(lngLat)
return
}
emit("map-click", {
lngLat,
screen,
overlayLayer: activeLayerStack.value[0] || null,
})
const picked = viewer.scene.pick(movement.position)
const pickedEntity = picked?.id
if (pickedEntity?.__hyTownName && selectTownByName(pickedEntity.__hyTownName)) return
const pickedTownFeature = findSelectableTownshipFeatureByLngLat(lngLat)
if (pickedTownFeature && selectTownByName(pickedTownFeature.properties?.name)) return
if (pickedEntity?.__hyFeature) {
selectEntity(pickedEntity)
emit("feature-info", {
feature: {
id: pickedEntity.__hyFeature.id,
properties: pickedEntity.__hyFeature.properties || {},
feature: pickedEntity.__hyFeature,
layer: pickedEntity.__hyLayer,
lngLat,
},
screen,
})
return
}
if (topicImageryLayers.length) {
emit("feature-loading", true)
try {
const ray = viewer.camera.getPickRay(movement.position)
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 properties = info.properties || info.data?.properties || info.data || {}
emit("feature-info", {
feature: {
id: properties.id || properties.fid || info.name,
properties,
feature: info,
layer: imageryLayer?.__hyLayer || activeLayerStack.value[0],
lngLat,
},
screen,
})
} else {
emit("feature-info", { feature: null, screen })
}
} catch (error) {
emit("feature-info", { feature: null, screen })
} finally {
emit("feature-loading", false)
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
}
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)
viewer?.scene?.requestRender?.()
}
function addSelectedFeatureOverlay(feature, options = {}) {
if (!viewer || !feature?.geometry) return []
const entities = addGeoFeature(feature, { key: "selected-feature-overlay", opacity: 1 }, selectedOverlayEntities, {
fillColor: options.mode === "town" ? "#30DCFF" : "#FF3B4D",
strokeColor: options.mode === "town" ? "#F2FFFF" : "#FFECEC",
fillAlpha: options.mode === "town" ? 0.16 : 0.2,
strokeAlpha: 0.98,
lineWidth: options.mode === "town" ? 4.2 : 4.8,
pointSize: 0,
})
entities.forEach((entity) => {
entity.__hyFeature = null
entity.__hyLayer = null
entity.__hyTownName = options.townName || feature.properties?.name || ""
entity.__hySelectedOverlay = true
if (entity.polyline) {
entity.polyline.width = options.mode === "town" ? 4.2 : 4.8
entity.polyline.clampToGround = true
}
if (entity.polygon) {
entity.polygon.classificationType = window.Cesium.ClassificationType.BOTH
}
})
return entities
}
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(name) {
const target = findTopicEntity((feature) => {
const properties = feature.properties || {}
return [properties.name, properties.project_name, properties.projectName, properties.title].some((value) => String(value || "") === String(name || ""))
})
if (!target) return null
viewer.flyTo(target, { duration: 0.9, offset: createFocusOffset(18000) })
selectEntity(target)
return target
}
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 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)
if (!payload.locateOnly) {
grasslands.forEach((feature) => {
addGeoFeature(feature, { key: "pasture-focus-glow", name: payload.name || "选中草场" }, focusEntities, {
fillColor: "#30DCFF",
strokeColor: "#30DCFF",
fillAlpha: 0.1,
strokeAlpha: 0.58,
lineWidth: 8.2,
})
addGeoFeature(feature, { key: "pasture-focus", name: payload.name || "选中草场" }, focusEntities, {
fillColor: "#FFCB45",
strokeColor: "#FFF4B8",
fillAlpha: 0.18,
strokeAlpha: 1,
lineWidth: 4.6,
})
})
;(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 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 })
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 = {}) {
removeEntities(analysisEntities)
const boundary = payload.boundary?.type === "Feature" ? payload.boundary : payload.boundary?.geometry ? payload.boundary : null
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 (!payload.preserveView) {
const features = [boundary, ...(payload.matches || [])].filter(Boolean)
const bounds = getFeaturesBounds(features)
if (bounds) flyToBounds(bounds, 0.9)
}
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 toLogicalScreen(Cesium.SceneTransforms.wgs84ToWindowCoordinates(viewer.scene, cartesian))
}
function toLogicalScreen(point) {
if (!point || !containerRef.value) return null
const width = Math.max(1, containerRef.value.clientWidth)
const height = Math.max(1, containerRef.value.clientHeight)
return {
x: (Number(point.x) / width) * 1920,
y: (Number(point.y) / height) * 1080,
}
}
function findTopicEntity(predicate) {
return [...topicEntities, ...focusEntities].find((entity) => entity.__hyFeature && predicate(entity.__hyFeature)) || 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 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 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 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,
clearSelectedFeature,
clearFloatingOverlays,
focusProject,
focusPasture,
clearPastureFocus,
focusTown,
focusYakIndustryPoint,
focusVectorFeature,
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>