import * as KMap from "@/utils/ol-map/KMap"; import * as util from "@/common/ol_common.js"; import config from "@/api/config.js"; import StaticImgLayer from "@/utils/ol-map/StaticImgLayer"; import { Vector as VectorSource } from "ol/source.js"; import Style from "ol/style/Style"; import Text from "ol/style/Text"; import Icon from "ol/style/Icon"; import { Fill, Stroke } from "ol/style"; import { WKT } from "ol/format"; import Feature from "ol/Feature"; import Point from "ol/geom/Point"; import { createEmpty, extend as extendExtent, isEmpty as isEmptyExtent, buffer as bufferExtent, getWidth, getHeight, } from "ol/extent"; /** PNG 农场影像底图:extent 为 [minLon, minLat, maxLon, maxLat],仅以下农场有数据 */ const FARM_BASE_IMAGE_LAYERS = [ { name: "东莞市潢涌村水稻基地", farmId: 320, url: "https://birdseye-img.sysuimars.com/platform-rs/farm_320_20260314.png", extent: [113.48318638854593, 22.90042586164938, 113.98019565717107, 23.36008435819883], }, { name: "莞荔园", farmId: 319, url: "https://birdseye-img.sysuimars.com/platform-rs/farm_319_20260314.png", extent: [113.49887532172826, 22.74031995946140, 113.99520269126261, 23.19988233865987], }, ]; const FARM_BASE_IMAGE_FARM_IDS = new Set(FARM_BASE_IMAGE_LAYERS.map((item) => item.farmId)); export function hasFarmBaseImage(farmId) { if (farmId == null || farmId === "") return false; return FARM_BASE_IMAGE_FARM_IDS.has(Number(farmId)); } const WKT_FORMAT = new WKT(); const RECORD_TYPE_STYLE = { zone: { fill: "rgba(28, 158, 128, 0.45)", stroke: "#1c9e80" }, growth: { fill: "rgba(255, 120, 0, 0.5)", stroke: "#cc5500" }, pest: { fill: "rgba(224, 49, 49, 0.45)", stroke: "#e03131" }, album: { fill: "rgba(255, 255, 255, 0.28)", stroke: "#ffffff" }, }; /** 物候→管理分区,农事→管理分区,异常→病虫害异常,相册→白色分区 */ const TAB_STYLE_TYPE = { phenology: "zone", farming: "zone", abnormal: "pest", album: "album", }; export const RECORD_TAB_KEYS = ["phenology", "farming", "abnormal"]; const DEFAULT_CENTER = "POINT(113.6142086995688 23.585836479509055)"; function roundRectPath(ctx, x, y, width, height, radius) { const r = Math.min(radius, width / 2, height / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + width, y, x + width, y + height, r); ctx.arcTo(x + width, y + height, x, y + height, r); ctx.arcTo(x, y + height, x, y, r); ctx.arcTo(x, y, x + width, y, r); ctx.closePath(); } function createHiDpiCanvas(cssWidth, cssHeight) { const dpr = window.devicePixelRatio || 1; const canvas = document.createElement("canvas"); canvas.width = Math.ceil(cssWidth * dpr); canvas.height = Math.ceil(cssHeight * dpr); const ctx = canvas.getContext("2d"); ctx.scale(dpr, dpr); return { canvas, ctx, dpr }; } function iconFromCanvas(canvas, dpr, anchor) { return new Icon({ src: canvas.toDataURL(), scale: 1 / dpr, anchor, anchorXUnits: "fraction", anchorYUnits: "fraction", }); } function createZoneLabelStyle(name) { const text = String(name || ""); const font = "13px sans-serif"; const padX = 10; const padY = 4; const measure = document.createElement("canvas").getContext("2d"); measure.font = font; const textWidth = Math.ceil(measure.measureText(text).width); const boxW = textWidth + padX * 2; const boxH = 21; const { canvas, ctx, dpr } = createHiDpiCanvas(boxW + 8, boxH + 8); const x = 4; const y = 4; ctx.shadowColor = "rgba(0, 0, 0, 0.12)"; ctx.shadowBlur = 4; ctx.shadowOffsetY = 1; roundRectPath(ctx, x, y, boxW, boxH, 4); ctx.fillStyle = "#ffffff"; ctx.fill(); ctx.shadowColor = "transparent"; ctx.fillStyle = "#1F1F1F"; ctx.font = font; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(text, x + boxW / 2, y + boxH / 2); return new Style({ image: iconFromCanvas(canvas, dpr, [0.5, 0.5]), }); } function drawCoverImage(ctx, img, x, y, size) { const scale = Math.max(size / img.width, size / img.height); const dw = img.width * scale; const dh = img.height * scale; ctx.drawImage(img, x + (size - dw) / 2, y + (size - dh) / 2, dw, dh); } function createPhotoMarkerStyle(img, count) { const thumb = 42; const arrowH = 6; const arrowW = 10; const badgeH = 16; const countText = String(count ?? ""); const badgeW = Math.max(16, 8 + countText.length * 7); const cssW = thumb + 12; const cssH = 6 + thumb + arrowH; const { canvas, ctx, dpr } = createHiDpiCanvas(cssW, cssH); const thumbX = (cssW - thumb) / 2; const thumbY = 6; ctx.save(); roundRectPath(ctx, thumbX, thumbY, thumb, thumb, 6); ctx.clip(); if (img) { drawCoverImage(ctx, img, thumbX, thumbY, thumb); } else { ctx.fillStyle = "#d9d9d9"; ctx.fillRect(thumbX, thumbY, thumb, thumb); } ctx.restore(); roundRectPath(ctx, thumbX, thumbY, thumb, thumb, 6); ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 2; ctx.stroke(); const ax = thumbX + thumb / 2; const ay = thumbY + thumb - 1; ctx.beginPath(); ctx.moveTo(ax - arrowW / 2, ay); ctx.lineTo(ax + arrowW / 2, ay); ctx.lineTo(ax, ay + arrowH); ctx.closePath(); ctx.fillStyle = "#ffffff"; ctx.fill(); const bx = thumbX + thumb - badgeW + 4; const by = thumbY - 6; roundRectPath(ctx, bx, by, badgeW, badgeH, 8); ctx.fillStyle = "#ffffff"; ctx.fill(); ctx.fillStyle = "#1F1F1F"; ctx.font = "10px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(countText, bx + badgeW / 2, by + badgeH / 2 + 0.5); return new Style({ image: iconFromCanvas(canvas, dpr, [0.5, 1]), }); } function loadImage(src) { return new Promise((resolve) => { if (!src) { resolve(null); return; } const img = new Image(); if (/^https?:\/\//i.test(String(src))) { img.crossOrigin = "anonymous"; } img.onload = () => resolve(img); img.onerror = () => resolve(null); img.src = src; }); } function createPointGeometry(lng, lat, projection) { const geometry = new Point([lng, lat]); if (projection && projection.getCode() !== "EPSG:4326") { geometry.transform("EPSG:4326", projection); } return geometry; } function getItemPolygon(item) { const wkt = item?.polygon ?? item?.geom ?? item?.geomWkt; return typeof wkt === "string" ? wkt.trim() : ""; } function readPolygonGeometry(wkt, projection) { return WKT_FORMAT.readGeometry(wkt, { dataProjection: "EPSG:4326", featureProjection: projection, }); } function createRecordFeature(wkt, item, projection, tabKey) { const feature = new Feature({ geometry: readPolygonGeometry(wkt, projection), }); feature.set("zone_name", item.zone_name); feature.set("styleType", TAB_STYLE_TYPE[tabKey] || "zone"); return feature; } function getUniqueExtents(features) { const seen = new Set(); const extents = []; features.forEach((f) => { const e = f.getGeometry()?.getExtent(); if (!e || !Number.isFinite(e[0])) return; const key = e.map((v) => v.toFixed(6)).join(","); if (seen.has(key)) return; seen.add(key); extents.push(e); }); return extents; } function resolveFitExtent(features) { const extents = getUniqueExtents(features); if (!extents.length) return null; const fitOne = (e) => { const span = Math.max(getWidth(e), getHeight(e), 0.00001); return bufferExtent(e, span * 0.35); }; if (extents.length === 1) return fitOne(extents[0]); const union = createEmpty(); extents.forEach((e) => extendExtent(union, e)); if (getWidth(union) > 0.15 || getHeight(union) > 0.15) { return fitOne(extents[0]); } return bufferExtent(union, Math.max(getWidth(union), getHeight(union), 0.00001) * 0.12); } export function recordsToCenterPoint(records) { const wkt = getItemPolygon(records?.[0]); if (!wkt) return null; try { const c = util.wktCastGeom(wkt).getFirstCoordinate(); return `POINT(${c[0]} ${c[1]})`; } catch { return null; } } class FileMap { constructor() { this._pending = null; this._pendingMarkers = null; this._fitTimer = null; this._renderToken = 0; this.baseImageLayers = []; this.baseImageVisible = false; this.currentFarmId = null; const vectorStyle = new KMap.VectorStyle(); this.recordPolygonLayer = new KMap.VectorLayer("fileRecordPolygonLayer", 1000, { minZoom: 8, maxZoom: 22, source: new VectorSource({}), style: (f) => { const colors = RECORD_TYPE_STYLE[f.get("styleType")] || RECORD_TYPE_STYLE.zone; const polygonStyle = vectorStyle.getPolygonStyle(colors.fill, colors.stroke, 2); const label = f.get("zone_name"); if (!label || f.get("styleType") === "album") return [polygonStyle]; return [ polygonStyle, new Style({ text: new Text({ font: "12px sans-serif", text: label, fill: new Fill({ color: "#fff" }), stroke: new Stroke({ color: "#000", width: 0.5 }), }), }), ]; }, }); this.albumMarkerLayer = new KMap.VectorLayer("fileAlbumMarkerLayer", 1001, { minZoom: 8, maxZoom: 22, source: new VectorSource({}), }); } initBaseImageLayers() { FARM_BASE_IMAGE_LAYERS.forEach((item) => { const imgLayer = new StaticImgLayer( item.url, { extent: item.extent, minZoom: 8, maxZoom: 22, opacity: 0.4, }, 4, this.kmap, ); this.baseImageLayers.push({ farmId: item.farmId, layer: imgLayer }); }); this.applyBaseImageVisibility(); } applyBaseImageVisibility() { const canShow = this.baseImageVisible && hasFarmBaseImage(this.currentFarmId); this.baseImageLayers.forEach(({ farmId, layer }) => { const shouldShow = canShow && farmId === this.currentFarmId; if (shouldShow) { layer.show(); } else { layer.hide(); } }); } showBaseImageByFarmId(farmId) { this.currentFarmId = farmId != null && farmId !== "" ? Number(farmId) : null; if (!hasFarmBaseImage(this.currentFarmId)) { this.baseImageVisible = false; } this.applyBaseImageVisibility(); } toggleBaseImageLayers(visible = !this.baseImageVisible) { this.baseImageVisible = visible && hasFarmBaseImage(this.currentFarmId); this.applyBaseImageVisibility(); } initMap(centerWkt, target) { if (!target) return; const center = util.wktCastGeom(centerWkt || DEFAULT_CENTER).getFirstCoordinate(); if (this.kmap?.map) { this.kmap.map.setTarget(target); this.kmap.map.updateSize(); this.flushPending(); return; } this.kmap = new KMap.Map(target, 16, center[0], center[1], null, 8, 22); this.kmap.addXYZLayer(config.base_img_url3 + "map/lby/{z}/{x}/{y}.png", { minZoom: 8, maxZoom: 22 }, 2); this.initBaseImageLayers(); this.kmap.addLayer(this.recordPolygonLayer.layer); this.kmap.addLayer(this.albumMarkerLayer.layer); this.flushPending(); } flushPending() { if (this._pending) { const pending = this._pending; this._pending = null; this.setRecordPolygons(pending.records, pending.tabKey); } if (this._pendingMarkers) { const pending = this._pendingMarkers; this._pendingMarkers = null; this.setAlbumMarkers(pending); } } setRecordPolygons(records, tabKey = "phenology") { if (!this.kmap) { this._pending = { records, tabKey }; return; } if (this._fitTimer) { clearTimeout(this._fitTimer); this._fitTimer = null; } const renderToken = ++this._renderToken; const projection = this.kmap.map.getView().getProjection(); const source = this.recordPolygonLayer.source; source.clear(true); const list = Array.isArray(records) ? records : []; const seenWkt = new Set(); list.forEach((item) => { const wkt = getItemPolygon(item); if (!wkt || seenWkt.has(wkt)) return; seenWkt.add(wkt); try { source.addFeature(createRecordFeature(wkt, item, projection, tabKey)); } catch (e) { console.warn("[FileMap] polygon parse failed", e); } }); this.recordPolygonLayer.layer.changed(); source.changed(); this.fitView(renderToken); } setAlbumMarkers({ labels = [], photos = [] } = {}) { if (!this.kmap) { this._pendingMarkers = { labels, photos }; return; } const source = this.albumMarkerLayer.source; source.clear(true); const projection = this.kmap.map.getView().getProjection(); labels.forEach((item) => { if (!item?.name || item.longitude == null || item.latitude == null) return; const feature = new Feature({ geometry: createPointGeometry(item.longitude, item.latitude, projection), }); feature.setStyle(createZoneLabelStyle(item.name)); source.addFeature(feature); }); photos.forEach((item) => { if (item.longitude == null || item.latitude == null) return; const feature = new Feature({ geometry: createPointGeometry(item.longitude, item.latitude, projection), }); feature.setStyle(createPhotoMarkerStyle(null, item.count)); source.addFeature(feature); loadImage(item.cover).then((img) => { feature.setStyle(createPhotoMarkerStyle(img, item.count)); }); }); this.albumMarkerLayer.layer.changed(); source.changed(); } fitView(renderToken) { if (!this.kmap?.map) return; if (renderToken != null && renderToken !== this._renderToken) return; const map = this.kmap.map; map.updateSize(); const features = this.recordPolygonLayer.source.getFeatures(); const extent = resolveFitExtent(features); if (!extent || isEmptyExtent(extent)) return; const size = map.getSize(); if (!size || size[0] < 4 || size[1] < 4) { this._fitTimer = setTimeout(() => this.fitView(renderToken), 120); return; } const view = this.kmap.getView(); view.cancelAnimations?.(); // Tab 切换时不用动画,避免连续 fit 互相打断导致视图停在上一 Tab 区域 view.fit(extent, { size, duration: 0, padding: [100, 40, 40, 40], maxZoom: 19, }); if ((view.getZoom() ?? 0) < 16) { view.setZoom(16); } } } export default FileMap;