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.
 
 
 
 
 
 
hy-screen-2.0/src/views/yakManagement/YakOlMap.vue

1375 lines
40 KiB

<template>
<div class="yak-ol-map">
<div :ref="setMapElement" class="yak-ol-map__canvas"></div>
<div
v-if="coordinateCopyTip.visible"
class="yak-ol-map__copy-tip"
:class="{ 'is-error': coordinateCopyTip.error }"
:style="{ left: `${coordinateCopyTip.x}px`, top: `${coordinateCopyTip.y}px` }"
>
{{ coordinateCopyTip.text }}
</div>
<div
v-if="coordinateCopyMenu.visible"
class="yak-ol-map__copy-menu"
:class="{ 'is-error': coordinateCopyMenu.error }"
:style="{ left: `${coordinateCopyMenu.x}px`, top: `${coordinateCopyMenu.y}px` }"
@click.stop
>
<span>{{ coordinateCopyMenu.text }}</span>
<button type="button" @click="handleCoordinateMenuCopy">复制</button>
</div>
</div>
</template>
<script setup>
import "ol/ol.css"
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import Map from "ol/Map.js"
import View from "ol/View.js"
import Feature from "ol/Feature.js"
import GeoJSON from "ol/format/GeoJSON.js"
import ImageLayer from "ol/layer/Image.js"
import TileLayer from "ol/layer/Tile.js"
import VectorLayer from "ol/layer/Vector.js"
import Point from "ol/geom/Point.js"
import ImageWMS from "ol/source/ImageWMS.js"
import TileWMS from "ol/source/TileWMS.js"
import XYZ from "ol/source/XYZ.js"
import VectorSource from "ol/source/Vector.js"
import { bbox as bboxStrategy } from "ol/loadingstrategy.js"
import { defaults as defaultControls } from "ol/control/defaults.js"
import { unByKey } from "ol/Observable.js"
import { fromLonLat, toLonLat } from "ol/proj.js"
import CircleStyle from "ol/style/Circle.js"
import Fill from "ol/style/Fill.js"
import RegularShape from "ol/style/RegularShape.js"
import Stroke from "ol/style/Stroke.js"
import Style from "ol/style/Style.js"
import Text from "ol/style/Text.js"
import { hongyuanTownshipLabelPoints, hongyuanTownshipsGeoJson } from "@/config/townshipBoundaries"
const props = defineProps({
active: {
type: Boolean,
default: true,
},
tileUrlTemplate: {
type: String,
default: "https://qkl-map.oss-cn-chengdu.aliyuncs.com/hy-result/{z}/{x}/{y}.png",
},
center: {
type: Array,
default: () => [103.07984, 33.029058],
},
minZoom: {
type: Number,
default: 3,
},
maxZoom: {
type: Number,
default: 21,
},
initialZoom: {
type: Number,
default: 21,
},
overlayLayer: {
type: Object,
default: () => ({
sourceType: "wms",
layerName: "ne:daping_yak_marks",
styles: "yak_marks",
cqlFilter: "gov_show = true",
name: "牦牛识别标记",
color: "#FFFF00",
opacity: 0.96,
}),
},
overlayLayers: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(["ready", "tile-status", "layer-status", "map-click"])
const HONGYUAN_CENTER = [103.07984, 33.029058]
const INITIAL_ZOOM = 21
const TOWN_BOUNDARY_GLOW_Z_INDEX = 232
const TOWN_BOUNDARY_LINE_Z_INDEX = 233
const TOWN_LABEL_LAYER_Z_INDEX = 240
const boundaryFormat = new GeoJSON()
const boundaryGlowStyle = new Style({
stroke: new Stroke({
color: "rgba(1, 18, 27, 0.72)",
width: 3.4,
}),
})
const boundaryLineStyle = new Style({
fill: new Fill({
color: "rgba(3, 18, 31, 0)",
}),
stroke: new Stroke({
color: "rgba(126, 251, 246, 0.9)",
width: 1.15,
}),
})
const pastureFocusStyle = [
new Style({
fill: new Fill({ color: "rgba(255, 76, 96, 0)" }),
stroke: new Stroke({ color: "rgba(255, 76, 96, 0.4)", width: 8 }),
}),
new Style({
stroke: new Stroke({ color: "rgba(255, 48, 68, 0.98)", width: 2.8 }),
}),
]
const pastureFocusYakStyle = [
new Style({
fill: new Fill({ color: "rgba(255, 255, 0, 0)" }),
stroke: new Stroke({ color: "rgba(38, 50, 0, 0.9)", width: 3 }),
}),
new Style({
fill: new Fill({ color: "rgba(255, 255, 0, 0)" }),
stroke: new Stroke({ color: "rgba(255, 255, 0, 1)", width: 1.4 }),
}),
new Style({
image: new CircleStyle({
radius: 3,
fill: new Fill({ color: "rgba(255, 255, 0, 0)" }),
stroke: new Stroke({ color: "rgba(255, 255, 0, 1)", width: 1.4 }),
}),
}),
]
const labelPointStyle = new Style({
image: new CircleStyle({
radius: 2.4,
fill: new Fill({ color: "rgba(234, 255, 255, 0.92)" }),
stroke: new Stroke({ color: "rgba(48, 220, 255, 0.9)", width: 1 }),
}),
})
const mapEl = ref(null)
let olMap = null
let baseSource = null
let overlaySource = null
let overlaySources = []
let overlayMapLayer = null
let overlayMapLayers = []
let pastureFocusSource = null
let pastureFocusLayer = null
let pastureFocusYakSource = null
let pastureFocusYakLayer = null
let pastureFocusYakRequestId = 0
let contextMenuHandler = null
let rightPointerDownHandler = null
let coordinateTipTimer = null
let lastRightClickCopyTime = 0
let eventKeys = []
let wmsEventKeys = []
const coordinateCopyTip = ref({
visible: false,
text: "",
x: 0,
y: 0,
error: false,
})
const coordinateCopyMenu = ref({
visible: false,
text: "",
x: 0,
y: 0,
error: false,
})
const pendingTiles = new Set()
const tileState = {
requested: 0,
loaded: 0,
failed: 0,
zoom: INITIAL_ZOOM,
}
function setMapElement(el) {
mapEl.value = el || null
if (el) initMap()
}
function tileCoordKey(event) {
const coord = event?.tile?.getTileCoord?.()
return Array.isArray(coord) ? coord.join("/") : ""
}
function currentZoom() {
const zoom = Number(olMap?.getView?.()?.getZoom?.())
return Number.isFinite(zoom) ? Math.round(zoom) : normalizeInitialZoom()
}
function normalizeCenter(center = props.center) {
const [lng, lat] = Array.isArray(center) ? center : []
const nextLng = Number(lng)
const nextLat = Number(lat)
return Number.isFinite(nextLng) && Number.isFinite(nextLat) ? [nextLng, nextLat] : HONGYUAN_CENTER
}
function locateMap(center = props.center) {
const view = olMap?.getView?.()
if (!view) return
const zoom = normalizeInitialZoom()
resetTileState(zoom)
view.setCenter(fromLonLat(normalizeCenter(center)))
view.setZoom(zoom)
window.setTimeout(() => olMap?.updateSize?.(), 0)
}
function normalizeInitialZoom() {
const zoom = Number(props.initialZoom)
const minZoom = Number(props.minZoom)
const maxZoom = Number(props.maxZoom)
const safeZoom = Number.isFinite(zoom) ? zoom : INITIAL_ZOOM
return Math.min(Number.isFinite(maxZoom) ? maxZoom : INITIAL_ZOOM, Math.max(Number.isFinite(minZoom) ? minZoom : 0, safeZoom))
}
function resetTileState(zoom = currentZoom()) {
pendingTiles.clear()
tileState.requested = 0
tileState.loaded = 0
tileState.failed = 0
tileState.zoom = zoom
}
function emitTileStatus(type, text) {
emit("tile-status", {
type,
text,
loaded: tileState.loaded,
failed: tileState.failed,
total: tileState.requested,
zoom: tileState.zoom,
tileUrlTemplate: props.tileUrlTemplate,
})
}
function emitLayerStatus(type, text) {
emit("layer-status", {
type,
text,
})
}
function buildBaseSource() {
baseSource = new XYZ({
url: props.tileUrlTemplate,
minZoom: props.minZoom,
maxZoom: props.maxZoom,
crossOrigin: "anonymous",
interpolate: true,
transition: 0,
wrapX: false,
})
eventKeys.push(
baseSource.on("tileloadstart", (event) => {
const key = tileCoordKey(event)
if (key && pendingTiles.has(key)) return
if (key) pendingTiles.add(key)
tileState.requested += 1
tileState.zoom = currentZoom()
emitTileStatus("loading", `正在加载 hy-result ${tileState.zoom}级影像底图`)
}),
)
eventKeys.push(
baseSource.on("tileloadend", (event) => {
const key = tileCoordKey(event)
if (key && !pendingTiles.has(key)) return
if (key) pendingTiles.delete(key)
tileState.loaded += 1
tileState.zoom = currentZoom()
const done = pendingTiles.size === 0
emitTileStatus(done ? "success" : "loading", done ? "hy-result 影像底图已加载" : `正在加载 hy-result ${tileState.zoom}级影像底图`)
}),
)
eventKeys.push(
baseSource.on("tileloaderror", (event) => {
const key = tileCoordKey(event)
if (key && !pendingTiles.has(key)) return
if (key) pendingTiles.delete(key)
tileState.failed += 1
tileState.zoom = currentZoom()
const type = tileState.loaded > 0 ? "warning" : "error"
emitTileStatus(type, type === "warning" ? "hy-result 影像底图覆盖不足" : "hy-result 影像底图加载失败")
}),
)
return baseSource
}
function normalizeOverlayLayer(layer = props.overlayLayer) {
if (!layer || layer.visible === false || !layer.layerName) return null
const sourceType = layer.sourceType || "wms"
return {
sourceType,
url: layer.url || (sourceType === "wfs" ? "/geoserver/ne/wfs" : "/geoserver/ne/wms"),
renderMode: layer.renderMode || "tile",
layerName: layer.layerName,
styles: layer.styles || "",
sldBody: layer.sldBody || "",
cqlFilter: layer.cqlFilter || "",
name: layer.name || "业务图层",
color: layer.color || layer.strokeColor || layer.fillColor || "#FFFF00",
fillColor: layer.fillColor || layer.color || "rgba(255, 227, 90, 0.05)",
strokeColor: layer.strokeColor || layer.color || "#FFFF00",
glowColor: layer.glowColor || "rgba(255, 227, 90, 0.3)",
opacity: Number.isFinite(Number(layer.opacity)) ? Number(layer.opacity) : 0.96,
lineWidth: Number.isFinite(Number(layer.lineWidth)) ? Number(layer.lineWidth) : 2.6,
zIndex: Number.isFinite(Number(layer.zIndex)) ? Number(layer.zIndex) : 20,
maxFeatures: layer.maxFeatures || 12000,
geometryField: layer.geometryField || "geom",
loadingStrategy: layer.loadingStrategy || "",
propertyName: layer.propertyName || (layer.boundaryOnly ? [layer.geometryField || "geom"] : undefined),
bboxPadding: Number.isFinite(Number(layer.bboxPadding)) ? Number(layer.bboxPadding) : 0.003,
}
}
function normalizeOverlayLayers() {
const layers = Array.isArray(props.overlayLayers) ? props.overlayLayers.filter(Boolean) : []
const sourceLayers = layers.length ? layers : [props.overlayLayer].filter(Boolean)
return sourceLayers.map((layer) => normalizeOverlayLayer(layer)).filter(Boolean)
}
function registerOverlaySource(source) {
if (!source) return source
overlaySource = source
overlaySources.push(source)
return source
}
function isActiveOverlaySource(source) {
return Boolean(source && overlaySources.includes(source))
}
function buildOverlayWmsParams(layer) {
const params = {
SERVICE: "WMS",
VERSION: "1.1.1",
LAYERS: layer.layerName,
FORMAT: "image/png",
TRANSPARENT: true,
TILED: true,
}
if (layer.styles) params.STYLES = layer.styles
if (layer.cqlFilter) params.CQL_FILTER = layer.cqlFilter
if (layer.sldBody) params.SLD_BODY = layer.sldBody
return params
}
function bindOverlayImageEvents(source, layer) {
wmsEventKeys.push(source.on("imageloadstart", () => emitLayerStatus("loading", `正在加载${layer.name}`)))
wmsEventKeys.push(source.on("imageloadend", () => emitLayerStatus("success", `${layer.name}已加载`)))
wmsEventKeys.push(source.on("imageloaderror", () => emitLayerStatus("error", `${layer.name}加载失败`)))
}
function bindOverlayTileEvents(source, layer) {
wmsEventKeys.push(source.on("tileloadstart", () => emitLayerStatus("loading", `正在加载${layer.name}`)))
wmsEventKeys.push(source.on("tileloadend", () => emitLayerStatus("success", `${layer.name}已加载`)))
wmsEventKeys.push(source.on("tileloaderror", () => emitLayerStatus("error", `${layer.name}加载失败`)))
}
function buildOverlayImageWmsSource(layer) {
const source = new ImageWMS({
url: layer.url,
params: buildOverlayWmsParams(layer),
serverType: "geoserver",
crossOrigin: "anonymous",
ratio: 1,
})
bindOverlayImageEvents(source, layer)
return source
}
function buildOverlayWmsSource(layer) {
const source = new TileWMS({
url: layer.url,
params: buildOverlayWmsParams(layer),
serverType: "geoserver",
crossOrigin: "anonymous",
transition: 0,
})
bindOverlayTileEvents(source, layer)
return source
}
function combineCqlFilters(...filters) {
const clauses = filters.map((item) => String(item || "").trim()).filter(Boolean)
if (!clauses.length) return ""
return clauses.length === 1 ? clauses[0] : clauses.map((item) => `(${item})`).join(" AND ")
}
function buildOverlayWfsUrl(layer, options = {}) {
const params = new URLSearchParams({
service: "WFS",
version: "1.0.0",
request: "GetFeature",
typeName: layer.layerName,
outputFormat: "json",
maxFeatures: String(layer.maxFeatures),
})
const propertyName = options.propertyName ?? layer.propertyName
if (Array.isArray(propertyName) && propertyName.length) params.set("propertyName", propertyName.join(","))
else if (propertyName) params.set("propertyName", propertyName)
const cqlFilter = combineCqlFilters(layer.cqlFilter, options.cqlFilter)
if (cqlFilter) params.set("CQL_FILTER", cqlFilter)
return `${layer.url}?${params.toString()}`
}
function createBboxCqlFilter(layer, extent) {
const bottomLeft = toLonLat([extent[0], extent[1]])
const topRight = toLonLat([extent[2], extent[3]])
const padding = layer.bboxPadding
const minLng = Math.min(bottomLeft[0], topRight[0]) - padding
const minLat = Math.min(bottomLeft[1], topRight[1]) - padding
const maxLng = Math.max(bottomLeft[0], topRight[0]) + padding
const maxLat = Math.max(bottomLeft[1], topRight[1]) + padding
return `BBOX(${layer.geometryField},${minLng},${minLat},${maxLng},${maxLat})`
}
function buildOverlayBboxVectorSource(layer) {
const source = new VectorSource({
wrapX: false,
strategy: bboxStrategy,
})
const format = new GeoJSON()
const loadedFeatureIds = new Set()
source.setLoader((extent, resolution, projection, success, failure) => {
emitLayerStatus("loading", `正在加载${layer.name}`)
fetch(buildOverlayWfsUrl(layer, { cqlFilter: createBboxCqlFilter(layer, extent) }), { credentials: "include" })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
})
.then((geojson) => {
if (!isActiveOverlaySource(source)) return
const features = format.readFeatures(geojson, {
dataProjection: "EPSG:4326",
featureProjection: projection,
})
const nextFeatures = features.filter((feature) => {
const id = feature.getId?.()
if (!id || loadedFeatureIds.has(id)) return false
loadedFeatureIds.add(id)
return true
})
source.addFeatures(nextFeatures)
success?.(nextFeatures)
emitLayerStatus(features.length ? "success" : "warning", features.length ? `${layer.name}已加载` : `${layer.name}暂无数据`)
})
.catch(() => {
if (!isActiveOverlaySource(source)) return
source.removeLoadedExtent?.(extent)
failure?.()
emitLayerStatus("error", `${layer.name}加载失败`)
})
})
return source
}
function buildOverlayVectorSource(layer) {
if (layer.loadingStrategy === "bbox") return buildOverlayBboxVectorSource(layer)
const source = new VectorSource({ wrapX: false })
const format = new GeoJSON()
emitLayerStatus("loading", `正在加载${layer.name}`)
fetch(buildOverlayWfsUrl(layer), { credentials: "include" })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
})
.then((geojson) => {
if (!isActiveOverlaySource(source)) return
const features = format.readFeatures(geojson, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
})
source.clear(true)
source.addFeatures(features)
emitLayerStatus(features.length ? "success" : "warning", features.length ? `${layer.name}已加载` : `${layer.name}暂无数据`)
})
.catch(() => {
if (!isActiveOverlaySource(source)) return
emitLayerStatus("error", `${layer.name}加载失败`)
})
return source
}
function createOverlayVectorStyle(layer) {
const lineWidth = layer.lineWidth
return [
new Style({
fill: new Fill({ color: layer.fillColor }),
stroke: new Stroke({ color: layer.glowColor, width: lineWidth + 5 }),
}),
new Style({
stroke: new Stroke({ color: layer.strokeColor, width: lineWidth }),
}),
]
}
function createOverlayMapLayer(layerConfig = props.overlayLayer) {
const layer = normalizeOverlayLayer(layerConfig)
if (!layer) return null
if (layer.sourceType === "wfs") {
const source = registerOverlaySource(buildOverlayVectorSource(layer))
const overlayVectorStyle = createOverlayVectorStyle(layer)
return new VectorLayer({
source,
style: () => overlayVectorStyle,
zIndex: layer.zIndex,
renderBuffer: 160,
updateWhileAnimating: true,
updateWhileInteracting: true,
})
}
if (layer.renderMode === "image") {
const source = registerOverlaySource(buildOverlayImageWmsSource(layer))
return new ImageLayer({
source,
opacity: layer.opacity,
zIndex: layer.zIndex,
})
}
const source = registerOverlaySource(buildOverlayWmsSource(layer))
return new TileLayer({
source,
opacity: layer.opacity,
zIndex: layer.zIndex,
})
}
function refreshOverlayLayer() {
if (!olMap) return
const staleLayers = new Set([...overlayMapLayers, overlayMapLayer].filter(Boolean))
staleLayers.forEach((layer) => olMap.removeLayer(layer))
overlayMapLayers = []
overlayMapLayer = null
if (wmsEventKeys.length) {
unByKey(wmsEventKeys)
wmsEventKeys = []
}
overlaySource = null
overlaySources = []
overlayMapLayers = normalizeOverlayLayers().map((layer) => createOverlayMapLayer(layer)).filter(Boolean)
overlayMapLayer = overlayMapLayers[0] || null
overlayMapLayers.forEach((layer) => olMap.addLayer(layer))
}
function createBoundaryLayers() {
const boundaryFeatures = boundaryFormat.readFeatures(hongyuanTownshipsGeoJson, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
})
const boundarySource = new VectorSource({
features: boundaryFeatures,
})
const labelFeatures = hongyuanTownshipLabelPoints
.filter((item) => item.name && Array.isArray(item.center))
.map((item) => {
const feature = new Feature({
geometry: new Point(fromLonLat(item.center)),
name: item.name,
})
feature.setId(`yak-town-label-${item.name}`)
return feature
})
const labelSource = new VectorSource({
features: labelFeatures,
})
return [
new VectorLayer({
source: boundarySource,
style: () => boundaryGlowStyle,
zIndex: TOWN_BOUNDARY_GLOW_Z_INDEX,
renderBuffer: 80,
updateWhileAnimating: true,
updateWhileInteracting: true,
}),
new VectorLayer({
source: boundarySource,
style: () => boundaryLineStyle,
zIndex: TOWN_BOUNDARY_LINE_Z_INDEX,
renderBuffer: 80,
updateWhileAnimating: true,
updateWhileInteracting: true,
}),
new VectorLayer({
source: labelSource,
declutter: true,
zIndex: TOWN_LABEL_LAYER_Z_INDEX,
renderBuffer: 120,
updateWhileAnimating: true,
updateWhileInteracting: true,
style: createTownLabelStyle,
}),
]
}
function createPastureFocusLayer() {
pastureFocusSource = new VectorSource({ wrapX: false })
pastureFocusLayer = new VectorLayer({
source: pastureFocusSource,
style: () => pastureFocusStyle,
zIndex: 180,
renderBuffer: 160,
updateWhileAnimating: true,
updateWhileInteracting: true,
})
return pastureFocusLayer
}
function createPastureFocusYakLayer() {
pastureFocusYakSource = new VectorSource({ wrapX: false })
pastureFocusYakLayer = new VectorLayer({
source: pastureFocusYakSource,
style: () => pastureFocusYakStyle,
zIndex: 190,
renderBuffer: 160,
updateWhileAnimating: true,
updateWhileInteracting: true,
})
return pastureFocusYakLayer
}
function normalizePastureGrasslandRows(value) {
if (!value) return []
if (typeof value === "string") return [value]
if (Array.isArray(value)) return value
if (value?.type === "FeatureCollection" && Array.isArray(value.features)) return value.features
if (value?.type === "Feature" || isGeoJsonGeometry(value)) return [value]
if (Array.isArray(value?.rows)) return value.rows
if (Array.isArray(value?.data)) return value.data
if (value?.data) return normalizePastureGrasslandRows(value.data)
if (Array.isArray(value?.list)) return value.list
if (Array.isArray(value?.features)) return value.features
if (value?.wkt || value?.geo || value?.geom || value?.geometryWkt || value?.geometry_wkt || value?.shape || value?.location || value?.geometry) return [value]
return []
}
function normalizePastureGrasslandGeometry(row) {
if (typeof row === "string") return parseGeometryText(row)
if (row?.type === "Feature") return normalizePastureGrasslandGeometry(row.geometry)
if (isGeoJsonGeometry(row)) return row
if (typeof row?.geometry === "string") return parseGeometryText(row.geometry)
if (row?.geometry?.type === "Feature") return normalizePastureGrasslandGeometry(row.geometry.geometry)
if (isGeoJsonGeometry(row?.geometry)) return row.geometry
return parseGeometryText(row?.wkt || row?.geo || row?.geom || row?.geometryWkt || row?.geometry_wkt || row?.shape || row?.location)
}
function normalizePastureGrasslandProperties(row) {
if (!row || typeof row !== "object" || Array.isArray(row)) return { wkt: row }
if (row.type === "Feature") return row.properties || {}
return row
}
function parseGeometryText(value) {
const text = String(value || "").trim()
if (!text) return null
if (text.startsWith("{") || text.startsWith("[")) {
try {
const parsed = JSON.parse(text)
if (parsed?.type === "Feature") return normalizePastureGrasslandGeometry(parsed)
if (isGeoJsonGeometry(parsed)) return parsed
} catch (error) {
return null
}
}
return parseWktGeometry(text)
}
function isGeoJsonGeometry(value) {
return Boolean(
value &&
["Polygon", "MultiPolygon", "LineString", "MultiLineString", "Point", "MultiPoint"].includes(value.type) &&
value.coordinates
)
}
function parseWktGeometry(wkt) {
const text = String(wkt || "").trim()
if (!text) return null
const typeMatch = text.match(/^([A-Z]+)(?:\s+[A-Z]+)?\s*\(/i)
const type = typeMatch?.[1]?.toUpperCase()
if (!type) return null
const body = text.slice(text.indexOf("("))
if (type === "POLYGON") return { type: "Polygon", coordinates: parseWktPolygonBody(body) }
if (type === "MULTIPOLYGON") return { type: "MultiPolygon", coordinates: getWktChildGroups(body).map(parseWktPolygonBody).filter((polygon) => polygon.length) }
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) }
return null
}
function parseWktPolygonBody(body) {
return getWktChildGroups(body)
.map(parseWktLine)
.filter((ring) => ring.length >= 3)
}
function parseWktLine(text) {
return String(text || "").replace(/^\(+|\)+$/g, "").split(",").map(parseWktPoint).filter(Boolean)
}
function parseWktPoint(text) {
const point = String(text || "").replace(/^\(+|\)+$/g, "").trim().split(/\s+/).slice(0, 2).map(Number)
return point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]) ? [point[0], point[1]] : null
}
function getWktChildGroups(text) {
const trimmed = String(text || "").trim()
const inner = trimmed.startsWith("(") && trimmed.endsWith(")") ? trimmed.slice(1, -1) : trimmed
const groups = []
let depth = 0
let start = -1
Array.from(inner).forEach((char, index) => {
if (char === "(") {
if (depth === 0) start = index
depth += 1
} else if (char === ")") {
depth -= 1
if (depth === 0 && start >= 0) {
groups.push(inner.slice(start, index + 1))
start = -1
}
}
})
return groups.length ? groups : [inner]
}
function createPastureFocusFeatures(payload = {}) {
const format = new GeoJSON()
return [
...normalizePastureGrasslandRows(payload?.grasslands),
...normalizePastureGrasslandRows(payload?.wkt),
]
.map((row, index) => {
const geometry = normalizePastureGrasslandGeometry(row)
if (!geometry) return null
const properties = normalizePastureGrasslandProperties(row)
const feature = format.readFeature(
{
type: "Feature",
properties: {
...properties,
id: properties.id || properties.gid || `${payload?.name || "pasture"}-${index}`,
pasture_name: payload?.name || properties.pastureName || properties.name,
},
geometry,
},
{
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
},
)
feature.setId(properties.id || properties.gid || `${payload?.name || "pasture"}-${index}`)
return feature
})
.filter(Boolean)
}
function clearPastureFocusYaks() {
pastureFocusYakRequestId += 1
pastureFocusYakSource?.clear(true)
}
function createPastureFocusYakFeatures(rows = []) {
const format = new GeoJSON()
return normalizePastureGrasslandRows(rows)
.map((row, index) => {
const geometry = normalizePastureGrasslandGeometry(row)
if (!geometry) return null
const properties = normalizePastureGrasslandProperties(row)
const feature = format.readFeature(
{
type: "Feature",
properties: {
...properties,
id: properties.id || properties.yak_id || properties.yakId || `focus-yak-${index}`,
},
geometry,
},
{
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
},
)
feature.setId(properties.id || properties.yak_id || properties.yakId || `focus-yak-${index}`)
return feature
})
.filter(Boolean)
}
function loadPastureFocusYaks(rows = []) {
if (!olMap) return 0
if (!pastureFocusYakSource) {
olMap.addLayer(createPastureFocusYakLayer())
}
const requestId = ++pastureFocusYakRequestId
const yakFeatures = createPastureFocusYakFeatures(rows)
if (requestId !== pastureFocusYakRequestId) return 0
pastureFocusYakSource.clear(true)
pastureFocusYakSource.addFeatures(yakFeatures)
emitLayerStatus(
yakFeatures.length ? "success" : "warning",
yakFeatures.length ? `已加载选中草场牦牛识别${yakFeatures.length}处` : "选中草场暂无牦牛识别标记",
)
return yakFeatures.length
}
function focusPasture(payload = {}) {
if (!olMap) return false
clearFloatingOverlays()
const features = createPastureFocusFeatures(payload)
if (!features.length) {
emitLayerStatus("warning", "当前牧户暂无可定位数据")
return false
}
if (!pastureFocusSource) {
olMap.addLayer(createPastureFocusLayer())
}
pastureFocusSource.clear(true)
pastureFocusSource.addFeatures(features)
const extent = pastureFocusSource.getExtent()
if (!extent || extent.some((value) => !Number.isFinite(value))) {
emitLayerStatus("warning", "当前牧户暂无可定位数据")
return false
}
const size = olMap.getSize?.() || [1280, 720]
const horizontalPadding = Math.min(360, Math.max(96, Number(size[0] || 0) * 0.24))
olMap.getView()?.fit(extent, {
duration: 700,
maxZoom: 17,
padding: [96, horizontalPadding, 96, horizontalPadding],
})
loadPastureFocusYaks(payload?.yakMarks || payload?.yakMarksGeoJson || payload?.yaks || [])
emitLayerStatus("success", "已定位具体草场")
window.setTimeout(() => olMap?.updateSize?.(), 0)
return true
}
function clearPastureFocus() {
clearFloatingOverlays()
pastureFocusSource?.clear(true)
clearPastureFocusYaks()
}
function createTownLabelStyle(feature, resolution) {
const name = feature.get("name") || ""
const viewResolution = Number(resolution) || 0
const compact = viewResolution > 180
const close = viewResolution < 55
const hidden = viewResolution > 520
if (hidden) return labelPointStyle
const fontSize = close ? 14 : compact ? 11 : 12.5
const minWidthPadding = close ? [6, 12, 6, 12] : compact ? [4, 8, 4, 8] : [5, 10, 5, 10]
const triangleRadius = close ? 5 : compact ? 3.8 : 4.4
const offsetY = close ? -20 : compact ? -15 : -17
return [
new Style({
image: new RegularShape({
points: 3,
radius: triangleRadius,
rotation: Math.PI,
displacement: [0, close ? 16 : compact ? 12 : 14],
fill: new Fill({ color: "rgba(126, 251, 246, 0.98)" }),
stroke: new Stroke({ color: "rgba(4, 28, 40, 0.95)", width: 1.2 }),
}),
}),
new Style({
text: new Text({
text: name,
font: `800 ${fontSize}px "Microsoft YaHei", "PingFang SC", sans-serif`,
fill: new Fill({ color: "#ffffff" }),
stroke: new Stroke({ color: "rgba(2, 18, 28, 1)", width: 5 }),
backgroundFill: new Fill({ color: "rgba(3, 21, 33, 0.9)" }),
backgroundStroke: new Stroke({ color: "rgba(126, 251, 246, 0.58)", width: 1.2 }),
padding: minWidthPadding,
offsetY,
}),
}),
]
}
function formatCoordinate(value) {
const number = Number(value)
if (!Number.isFinite(number)) return "0.000000"
return number.toFixed(6)
}
async function copyTextToClipboard(text) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return
} catch (error) {
// Fall back to textarea copy below. Some browsers do not allow clipboard writes from contextmenu.
}
}
const textarea = document.createElement("textarea")
textarea.value = text
textarea.setAttribute("readonly", "readonly")
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.top = "0"
textarea.style.opacity = "0"
try {
document.body.appendChild(textarea)
textarea.focus({ preventScroll: true })
textarea.select()
textarea.setSelectionRange(0, text.length)
const success = document.execCommand("copy")
if (!success) throw new Error("copy failed")
} finally {
document.body.removeChild(textarea)
}
}
function showCoordinateCopyTip(event, text, error = false) {
if (coordinateTipTimer) window.clearTimeout(coordinateTipTimer)
coordinateCopyMenu.value = {
...coordinateCopyMenu.value,
visible: false,
}
const tipWidth = 270
const tipHeight = 32
const viewportWidth = window.innerWidth || 1920
const viewportHeight = window.innerHeight || 1080
coordinateCopyTip.value = {
visible: true,
text,
x: Math.min(Math.max(12, event.clientX + 12), Math.max(12, viewportWidth - tipWidth)),
y: Math.min(Math.max(12, event.clientY + 12), Math.max(12, viewportHeight - tipHeight)),
error,
}
coordinateTipTimer = window.setTimeout(() => {
coordinateCopyTip.value = {
...coordinateCopyTip.value,
visible: false,
}
coordinateTipTimer = null
}, 1500)
}
function showCoordinateCopyMenu(event, text, error = false) {
if (coordinateTipTimer) {
window.clearTimeout(coordinateTipTimer)
coordinateTipTimer = null
}
const menuWidth = 300
const menuHeight = 38
const viewportWidth = window.innerWidth || 1920
const viewportHeight = window.innerHeight || 1080
coordinateCopyTip.value = {
...coordinateCopyTip.value,
visible: false,
}
coordinateCopyMenu.value = {
visible: true,
text,
x: Math.min(Math.max(12, event.clientX + 12), Math.max(12, viewportWidth - menuWidth)),
y: Math.min(Math.max(12, event.clientY + 12), Math.max(12, viewportHeight - menuHeight)),
error,
}
}
function hideCoordinateCopyMenu() {
coordinateCopyMenu.value = {
...coordinateCopyMenu.value,
visible: false,
}
}
function clearFloatingOverlays() {
if (coordinateTipTimer) {
window.clearTimeout(coordinateTipTimer)
coordinateTipTimer = null
}
coordinateCopyTip.value = {
...coordinateCopyTip.value,
visible: false,
}
hideCoordinateCopyMenu()
}
async function copyCoordinateFromMapEvent(event) {
if (!olMap) return
const pixel = olMap.getEventPixel(event)
const coordinate = olMap.getCoordinateFromPixel(pixel)
if (!coordinate) return
const [lng, lat] = toLonLat(coordinate)
const text = `${formatCoordinate(lng)}, ${formatCoordinate(lat)}`
try {
await copyTextToClipboard(text)
showCoordinateCopyTip(event, `已复制 ${text}`)
} catch (error) {
showCoordinateCopyMenu(event, text, true)
}
}
function handleRightPointerDown(event) {
if (event.button !== 2) {
hideCoordinateCopyMenu()
return
}
event.preventDefault()
event.stopPropagation()
lastRightClickCopyTime = Date.now()
copyCoordinateFromMapEvent(event)
}
async function handleCoordinateMenuCopy() {
const text = coordinateCopyMenu.value.text
if (!text) return
try {
await copyTextToClipboard(text)
coordinateCopyMenu.value = {
...coordinateCopyMenu.value,
visible: false,
error: false,
}
coordinateCopyTip.value = {
visible: true,
text: `已复制 ${text}`,
x: coordinateCopyMenu.value.x,
y: coordinateCopyMenu.value.y,
error: false,
}
if (coordinateTipTimer) window.clearTimeout(coordinateTipTimer)
coordinateTipTimer = window.setTimeout(() => {
coordinateCopyTip.value = {
...coordinateCopyTip.value,
visible: false,
}
coordinateTipTimer = null
}, 1500)
} catch (error) {
coordinateCopyMenu.value = {
...coordinateCopyMenu.value,
error: true,
}
}
}
function handleContextMenu(event) {
event.preventDefault()
event.stopPropagation()
if (Date.now() - lastRightClickCopyTime < 800) return
lastRightClickCopyTime = Date.now()
copyCoordinateFromMapEvent(event)
}
function handleMapSingleClick(event) {
if (!olMap || !event?.coordinate) return
hideCoordinateCopyMenu()
const [lng, lat] = toLonLat(event.coordinate)
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return
const sourceEvent = event.originalEvent || {}
emit("map-click", {
lngLat: { lng, lat },
coordinate: event.coordinate,
pixel: event.pixel,
screen: {
x: Number(sourceEvent.clientX),
y: Number(sourceEvent.clientY),
},
overlayLayer: props.overlayLayer,
overlayLayers: normalizeOverlayLayers(),
})
}
function bindContextMenuCopy() {
const viewport = olMap?.getViewport?.()
if (!viewport || contextMenuHandler) return
rightPointerDownHandler = (event) => {
handleRightPointerDown(event)
}
contextMenuHandler = (event) => {
handleContextMenu(event)
}
viewport.addEventListener("pointerdown", rightPointerDownHandler)
viewport.addEventListener("contextmenu", contextMenuHandler)
}
function unbindContextMenuCopy() {
const viewport = olMap?.getViewport?.()
if (viewport && rightPointerDownHandler) {
viewport.removeEventListener("pointerdown", rightPointerDownHandler)
}
if (viewport && contextMenuHandler) {
viewport.removeEventListener("contextmenu", contextMenuHandler)
}
rightPointerDownHandler = null
contextMenuHandler = null
clearFloatingOverlays()
}
async function initMap() {
if (olMap) return
await nextTick()
if (!mapEl.value || olMap) return
const initialZoom = normalizeInitialZoom()
resetTileState(initialZoom)
const view = new View({
center: fromLonLat(normalizeCenter()),
zoom: initialZoom,
minZoom: props.minZoom,
maxZoom: props.maxZoom,
enableRotation: false,
constrainResolution: false,
smoothResolutionConstraint: true,
})
olMap = new Map({
target: mapEl.value,
controls: defaultControls({
attribution: false,
rotate: false,
zoom: false,
}),
layers: [
new TileLayer({
source: buildBaseSource(),
preload: 1,
zIndex: 1,
}),
...createBoundaryLayers(),
createPastureFocusLayer(),
createPastureFocusYakLayer(),
],
view,
})
refreshOverlayLayer()
bindContextMenuCopy()
eventKeys.push(
olMap.on("singleclick", handleMapSingleClick),
)
eventKeys.push(
view.on("change:resolution", () => {
const zoom = currentZoom()
if (zoom !== tileState.zoom) {
resetTileState(zoom)
emitTileStatus("loading", `正在加载 hy-result ${zoom}级影像底图`)
}
}),
)
window.setTimeout(() => {
olMap?.updateSize?.()
emit("ready")
}, 0)
}
function disposeMap() {
unbindContextMenuCopy()
if (eventKeys.length) {
unByKey(eventKeys)
eventKeys = []
}
if (wmsEventKeys.length) {
unByKey(wmsEventKeys)
wmsEventKeys = []
}
pendingTiles.clear()
baseSource = null
overlaySource = null
overlaySources = []
overlayMapLayer = null
overlayMapLayers = []
pastureFocusSource = null
pastureFocusLayer = null
pastureFocusYakSource = null
pastureFocusYakLayer = null
pastureFocusYakRequestId += 1
if (olMap) {
olMap.setTarget(null)
olMap.dispose?.()
olMap = null
}
}
function refreshView() {
clearFloatingOverlays()
pastureFocusSource?.clear(true)
clearPastureFocusYaks()
locateMap()
olMap?.updateSize?.()
window.setTimeout(() => olMap?.updateSize?.(), 120)
}
function refreshTileSources() {
if (baseSource) {
resetTileState(currentZoom())
baseSource.refresh?.()
emitTileStatus("loading", `正在刷新 hy-result ${tileState.zoom}级影像底图`)
}
const sources = overlaySources.length ? overlaySources : [overlaySource].filter(Boolean)
sources.forEach((source) => source?.refresh?.())
}
function resetView() {
clearFloatingOverlays()
pastureFocusSource?.clear(true)
clearPastureFocusYaks()
locateMap()
refreshTileSources()
olMap?.updateSize?.()
window.setTimeout(() => olMap?.updateSize?.(), 80)
window.setTimeout(() => olMap?.updateSize?.(), 220)
}
function hasMap() {
return Boolean(olMap)
}
defineExpose({
refreshView,
resetView,
hasMap,
focusPasture,
clearPastureFocus,
clearFloatingOverlays,
})
onMounted(() => {
initMap()
})
onBeforeUnmount(() => {
disposeMap()
})
watch(
() => props.active,
(active) => {
if (active) {
initMap()
window.setTimeout(() => {
locateMap()
olMap?.updateSize?.()
}, 0)
}
},
)
watch(
() => props.center,
() => {
locateMap()
},
{ deep: true },
)
watch(
() => props.tileUrlTemplate,
() => {
if (!baseSource) return
resetTileState(currentZoom())
baseSource.setUrl(props.tileUrlTemplate)
baseSource.refresh()
emitTileStatus("loading", `正在加载 hy-result ${tileState.zoom}级影像底图`)
},
)
watch(
() => [props.overlayLayer, props.overlayLayers],
() => {
refreshOverlayLayer()
},
{ deep: true },
)
</script>
<style scoped lang="scss">
.yak-ol-map {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
background: #03121c;
}
.yak-ol-map__canvas {
width: 100%;
height: 100%;
}
.yak-ol-map__copy-tip {
position: fixed;
z-index: 30;
max-width: 260px;
padding: 7px 10px;
border: 1px solid rgba(126, 251, 246, 0.4);
border-radius: 3px;
color: rgba(235, 255, 255, 0.94);
font-size: 12px;
font-weight: 700;
line-height: 1;
background: rgba(3, 21, 33, 0.86);
box-shadow: 0 0 14px rgba(48, 220, 255, 0.16);
pointer-events: none;
transform: translateY(-50%);
white-space: nowrap;
&.is-error {
border-color: rgba(255, 143, 107, 0.55);
color: #ffe0d6;
}
}
.yak-ol-map__copy-menu {
position: fixed;
z-index: 30;
display: flex;
height: 34px;
align-items: center;
gap: 8px;
padding: 0 8px 0 10px;
border: 1px solid rgba(126, 251, 246, 0.42);
border-radius: 3px;
color: rgba(235, 255, 255, 0.94);
font-size: 12px;
font-weight: 700;
line-height: 1;
background: rgba(3, 21, 33, 0.92);
box-shadow: 0 0 14px rgba(48, 220, 255, 0.18);
pointer-events: all;
white-space: nowrap;
span {
font-variant-numeric: tabular-nums;
}
button {
height: 22px;
padding: 0 8px;
border: 1px solid rgba(126, 251, 246, 0.38);
border-radius: 2px;
color: #031822;
font-size: 12px;
font-weight: 800;
background: linear-gradient(180deg, #7efbf6, #30dcff);
cursor: pointer;
}
&.is-error {
border-color: rgba(255, 215, 108, 0.48);
}
}
:deep(.ol-viewport) {
background: #03121c;
}
</style>