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
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>
|
|
|