fileMap.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import * as KMap from "@/utils/ol-map/KMap";
  2. import * as util from "@/common/ol_common.js";
  3. import config from "@/api/config.js";
  4. import StaticImgLayer from "@/utils/ol-map/StaticImgLayer";
  5. import { Vector as VectorSource } from "ol/source.js";
  6. import Style from "ol/style/Style";
  7. import Text from "ol/style/Text";
  8. import Icon from "ol/style/Icon";
  9. import { Fill, Stroke } from "ol/style";
  10. import { WKT } from "ol/format";
  11. import Feature from "ol/Feature";
  12. import Point from "ol/geom/Point";
  13. import {
  14. createEmpty,
  15. extend as extendExtent,
  16. isEmpty as isEmptyExtent,
  17. buffer as bufferExtent,
  18. getWidth,
  19. getHeight,
  20. } from "ol/extent";
  21. /** PNG 农场影像底图:extent 为 [minLon, minLat, maxLon, maxLat],仅以下农场有数据 */
  22. const FARM_BASE_IMAGE_LAYERS = [
  23. {
  24. name: "东莞市潢涌村水稻基地",
  25. farmId: 320,
  26. url: "https://birdseye-img.sysuimars.com/platform-rs/farm_320_20260314.png",
  27. extent: [113.48318638854593, 22.90042586164938, 113.98019565717107, 23.36008435819883],
  28. },
  29. {
  30. name: "莞荔园",
  31. farmId: 319,
  32. url: "https://birdseye-img.sysuimars.com/platform-rs/farm_319_20260314.png",
  33. extent: [113.49887532172826, 22.74031995946140, 113.99520269126261, 23.19988233865987],
  34. },
  35. ];
  36. const FARM_BASE_IMAGE_FARM_IDS = new Set(FARM_BASE_IMAGE_LAYERS.map((item) => item.farmId));
  37. export function hasFarmBaseImage(farmId) {
  38. if (farmId == null || farmId === "") return false;
  39. return FARM_BASE_IMAGE_FARM_IDS.has(Number(farmId));
  40. }
  41. const WKT_FORMAT = new WKT();
  42. const RECORD_TYPE_STYLE = {
  43. zone: { fill: "rgba(28, 158, 128, 0.45)", stroke: "#1c9e80" },
  44. growth: { fill: "rgba(255, 120, 0, 0.5)", stroke: "#cc5500" },
  45. pest: { fill: "rgba(224, 49, 49, 0.45)", stroke: "#e03131" },
  46. album: { fill: "rgba(255, 255, 255, 0.28)", stroke: "#ffffff" },
  47. };
  48. /** 物候→管理分区,农事→管理分区,异常→病虫害异常,相册→白色分区 */
  49. const TAB_STYLE_TYPE = {
  50. phenology: "zone",
  51. farming: "zone",
  52. abnormal: "pest",
  53. album: "album",
  54. };
  55. export const RECORD_TAB_KEYS = ["phenology", "farming", "abnormal"];
  56. const DEFAULT_CENTER = "POINT(113.6142086995688 23.585836479509055)";
  57. function roundRectPath(ctx, x, y, width, height, radius) {
  58. const r = Math.min(radius, width / 2, height / 2);
  59. ctx.beginPath();
  60. ctx.moveTo(x + r, y);
  61. ctx.arcTo(x + width, y, x + width, y + height, r);
  62. ctx.arcTo(x + width, y + height, x, y + height, r);
  63. ctx.arcTo(x, y + height, x, y, r);
  64. ctx.arcTo(x, y, x + width, y, r);
  65. ctx.closePath();
  66. }
  67. function createHiDpiCanvas(cssWidth, cssHeight) {
  68. const dpr = window.devicePixelRatio || 1;
  69. const canvas = document.createElement("canvas");
  70. canvas.width = Math.ceil(cssWidth * dpr);
  71. canvas.height = Math.ceil(cssHeight * dpr);
  72. const ctx = canvas.getContext("2d");
  73. ctx.scale(dpr, dpr);
  74. return { canvas, ctx, dpr };
  75. }
  76. function iconFromCanvas(canvas, dpr, anchor) {
  77. // 用 canvas 本体,避免 toDataURL:跨域封面无 CORS 时 canvas 会污染,导出失败导致缩略图空白
  78. return new Icon({
  79. img: canvas,
  80. imgSize: [canvas.width, canvas.height],
  81. scale: 1 / dpr,
  82. anchor,
  83. anchorXUnits: "fraction",
  84. anchorYUnits: "fraction",
  85. });
  86. }
  87. function createZoneLabelStyle(name) {
  88. const text = String(name || "");
  89. const font = "13px sans-serif";
  90. const padX = 10;
  91. const padY = 4;
  92. const measure = document.createElement("canvas").getContext("2d");
  93. measure.font = font;
  94. const textWidth = Math.ceil(measure.measureText(text).width);
  95. const boxW = textWidth + padX * 2;
  96. const boxH = 21;
  97. const { canvas, ctx, dpr } = createHiDpiCanvas(boxW + 8, boxH + 8);
  98. const x = 4;
  99. const y = 4;
  100. ctx.shadowColor = "rgba(0, 0, 0, 0.12)";
  101. ctx.shadowBlur = 4;
  102. ctx.shadowOffsetY = 1;
  103. roundRectPath(ctx, x, y, boxW, boxH, 4);
  104. ctx.fillStyle = "#ffffff";
  105. ctx.fill();
  106. ctx.shadowColor = "transparent";
  107. ctx.fillStyle = "#1F1F1F";
  108. ctx.font = font;
  109. ctx.textAlign = "center";
  110. ctx.textBaseline = "middle";
  111. ctx.fillText(text, x + boxW / 2, y + boxH / 2);
  112. return new Style({
  113. image: iconFromCanvas(canvas, dpr, [0.5, 0.5]),
  114. });
  115. }
  116. function drawCoverImage(ctx, img, x, y, size) {
  117. const scale = Math.max(size / img.width, size / img.height);
  118. const dw = img.width * scale;
  119. const dh = img.height * scale;
  120. ctx.drawImage(img, x + (size - dw) / 2, y + (size - dh) / 2, dw, dh);
  121. }
  122. function createPhotoMarkerStyle(img, count) {
  123. const thumb = 42;
  124. const arrowH = 6;
  125. const arrowW = 10;
  126. const badgeH = 16;
  127. const countText = String(count ?? "");
  128. const badgeW = Math.max(16, 8 + countText.length * 7);
  129. const cssW = thumb + 12;
  130. const cssH = 6 + thumb + arrowH;
  131. const { canvas, ctx, dpr } = createHiDpiCanvas(cssW, cssH);
  132. const thumbX = (cssW - thumb) / 2;
  133. const thumbY = 6;
  134. ctx.save();
  135. roundRectPath(ctx, thumbX, thumbY, thumb, thumb, 6);
  136. ctx.clip();
  137. if (img) {
  138. drawCoverImage(ctx, img, thumbX, thumbY, thumb);
  139. } else {
  140. ctx.fillStyle = "#d9d9d9";
  141. ctx.fillRect(thumbX, thumbY, thumb, thumb);
  142. }
  143. ctx.restore();
  144. roundRectPath(ctx, thumbX, thumbY, thumb, thumb, 6);
  145. ctx.strokeStyle = "#ffffff";
  146. ctx.lineWidth = 2;
  147. ctx.stroke();
  148. const ax = thumbX + thumb / 2;
  149. const ay = thumbY + thumb - 1;
  150. ctx.beginPath();
  151. ctx.moveTo(ax - arrowW / 2, ay);
  152. ctx.lineTo(ax + arrowW / 2, ay);
  153. ctx.lineTo(ax, ay + arrowH);
  154. ctx.closePath();
  155. ctx.fillStyle = "#ffffff";
  156. ctx.fill();
  157. const bx = thumbX + thumb - badgeW + 4;
  158. const by = thumbY - 6;
  159. roundRectPath(ctx, bx, by, badgeW, badgeH, 8);
  160. ctx.fillStyle = "#ffffff";
  161. ctx.fill();
  162. ctx.fillStyle = "#1F1F1F";
  163. ctx.font = "10px sans-serif";
  164. ctx.textAlign = "center";
  165. ctx.textBaseline = "middle";
  166. ctx.fillText(countText, bx + badgeW / 2, by + badgeH / 2 + 0.5);
  167. return new Style({
  168. image: iconFromCanvas(canvas, dpr, [0.5, 1]),
  169. });
  170. }
  171. function loadImage(src) {
  172. return new Promise((resolve) => {
  173. if (!src) {
  174. resolve(null);
  175. return;
  176. }
  177. const img = new Image();
  178. // 不设 crossOrigin:相册 cloud_url 常无 CORS,强制 anonymous 会 onerror,标记只剩灰底
  179. img.onload = () => resolve(img);
  180. img.onerror = () => resolve(null);
  181. img.src = src;
  182. });
  183. }
  184. function createPointGeometry(lng, lat, projection) {
  185. const geometry = new Point([lng, lat]);
  186. if (projection && projection.getCode() !== "EPSG:4326") {
  187. geometry.transform("EPSG:4326", projection);
  188. }
  189. return geometry;
  190. }
  191. function getItemPolygon(item) {
  192. const wkt = item?.polygon ?? item?.geom ?? item?.geomWkt;
  193. return typeof wkt === "string" ? wkt.trim() : "";
  194. }
  195. function readPolygonGeometry(wkt, projection) {
  196. return WKT_FORMAT.readGeometry(wkt, {
  197. dataProjection: "EPSG:4326",
  198. featureProjection: projection,
  199. });
  200. }
  201. function createRecordFeature(wkt, item, projection, tabKey) {
  202. const feature = new Feature({
  203. geometry: readPolygonGeometry(wkt, projection),
  204. });
  205. feature.set("zone_name", item.zone_name);
  206. feature.set("styleType", TAB_STYLE_TYPE[tabKey] || "zone");
  207. return feature;
  208. }
  209. function getUniqueExtents(features) {
  210. const seen = new Set();
  211. const extents = [];
  212. features.forEach((f) => {
  213. const e = f.getGeometry()?.getExtent();
  214. if (!e || !Number.isFinite(e[0])) return;
  215. const key = e.map((v) => v.toFixed(6)).join(",");
  216. if (seen.has(key)) return;
  217. seen.add(key);
  218. extents.push(e);
  219. });
  220. return extents;
  221. }
  222. function resolveFitExtent(features, { bufferRatio = 0.35 } = {}) {
  223. const extents = getUniqueExtents(features);
  224. if (!extents.length) return null;
  225. const fitOne = (e) => {
  226. if (!bufferRatio) return e.slice();
  227. const span = Math.max(getWidth(e), getHeight(e), 0.00001);
  228. return bufferExtent(e, span * bufferRatio);
  229. };
  230. if (extents.length === 1) return fitOne(extents[0]);
  231. const union = createEmpty();
  232. extents.forEach((e) => extendExtent(union, e));
  233. if (getWidth(union) > 0.15 || getHeight(union) > 0.15) {
  234. return fitOne(extents[0]);
  235. }
  236. if (!bufferRatio) return union;
  237. return bufferExtent(union, Math.max(getWidth(union), getHeight(union), 0.00001) * 0.12);
  238. }
  239. export function recordsToCenterPoint(records) {
  240. const wkt = getItemPolygon(records?.[0]);
  241. if (!wkt) return null;
  242. try {
  243. const c = util.wktCastGeom(wkt).getFirstCoordinate();
  244. return `POINT(${c[0]} ${c[1]})`;
  245. } catch {
  246. return null;
  247. }
  248. }
  249. class FileMap {
  250. constructor() {
  251. this._pending = null;
  252. this._pendingMarkers = null;
  253. this._fitTimer = null;
  254. this._renderToken = 0;
  255. this.baseImageLayers = [];
  256. this.baseImageVisible = false;
  257. this.currentFarmId = null;
  258. const vectorStyle = new KMap.VectorStyle();
  259. this.recordPolygonLayer = new KMap.VectorLayer("fileRecordPolygonLayer", 1000, {
  260. minZoom: 8,
  261. maxZoom: 22,
  262. source: new VectorSource({}),
  263. style: (f) => {
  264. const colors = RECORD_TYPE_STYLE[f.get("styleType")] || RECORD_TYPE_STYLE.zone;
  265. const polygonStyle = vectorStyle.getPolygonStyle(colors.fill, colors.stroke, 2);
  266. const label = f.get("zone_name");
  267. if (!label || f.get("styleType") === "album") return [polygonStyle];
  268. return [
  269. polygonStyle,
  270. new Style({
  271. text: new Text({
  272. font: "12px sans-serif",
  273. text: label,
  274. fill: new Fill({ color: "#fff" }),
  275. stroke: new Stroke({ color: "#000", width: 0.5 }),
  276. }),
  277. }),
  278. ];
  279. },
  280. });
  281. this.albumMarkerLayer = new KMap.VectorLayer("fileAlbumMarkerLayer", 1001, {
  282. minZoom: 8,
  283. maxZoom: 22,
  284. source: new VectorSource({}),
  285. });
  286. }
  287. initBaseImageLayers() {
  288. FARM_BASE_IMAGE_LAYERS.forEach((item) => {
  289. const imgLayer = new StaticImgLayer(
  290. item.url,
  291. {
  292. extent: item.extent,
  293. minZoom: 8,
  294. maxZoom: 22,
  295. opacity: 0.4,
  296. },
  297. 4,
  298. this.kmap,
  299. );
  300. this.baseImageLayers.push({ farmId: item.farmId, layer: imgLayer });
  301. });
  302. this.applyBaseImageVisibility();
  303. }
  304. applyBaseImageVisibility() {
  305. const canShow = this.baseImageVisible && hasFarmBaseImage(this.currentFarmId);
  306. this.baseImageLayers.forEach(({ farmId, layer }) => {
  307. const shouldShow = canShow && farmId === this.currentFarmId;
  308. if (shouldShow) {
  309. layer.show();
  310. } else {
  311. layer.hide();
  312. }
  313. });
  314. }
  315. showBaseImageByFarmId(farmId) {
  316. this.currentFarmId = farmId != null && farmId !== "" ? Number(farmId) : null;
  317. if (!hasFarmBaseImage(this.currentFarmId)) {
  318. this.baseImageVisible = false;
  319. }
  320. this.applyBaseImageVisibility();
  321. }
  322. toggleBaseImageLayers(visible = !this.baseImageVisible) {
  323. this.baseImageVisible = visible && hasFarmBaseImage(this.currentFarmId);
  324. this.applyBaseImageVisibility();
  325. }
  326. initMap(centerWkt, target) {
  327. if (!target) return;
  328. const center = util.wktCastGeom(centerWkt || DEFAULT_CENTER).getFirstCoordinate();
  329. if (this.kmap?.map) {
  330. this.kmap.map.setTarget(target);
  331. this.kmap.map.updateSize();
  332. this.flushPending();
  333. return;
  334. }
  335. this.kmap = new KMap.Map(target, 16, center[0], center[1], null, 8, 22);
  336. this.kmap.addXYZLayer(config.base_img_url3 + "map/lby/{z}/{x}/{y}.png", { minZoom: 8, maxZoom: 22 }, 2);
  337. this.initBaseImageLayers();
  338. this.kmap.addLayer(this.recordPolygonLayer.layer);
  339. this.kmap.addLayer(this.albumMarkerLayer.layer);
  340. this.flushPending();
  341. }
  342. flushPending() {
  343. if (this._pending) {
  344. const pending = this._pending;
  345. this._pending = null;
  346. this.setRecordPolygons(pending.records, pending.tabKey);
  347. }
  348. if (this._pendingMarkers) {
  349. const pending = this._pendingMarkers;
  350. this._pendingMarkers = null;
  351. this.setAlbumMarkers(pending);
  352. }
  353. }
  354. setRecordPolygons(records, tabKey = "phenology") {
  355. if (!this.kmap) {
  356. this._pending = { records, tabKey };
  357. return;
  358. }
  359. if (this._fitTimer) {
  360. clearTimeout(this._fitTimer);
  361. this._fitTimer = null;
  362. }
  363. const renderToken = ++this._renderToken;
  364. const projection = this.kmap.map.getView().getProjection();
  365. const source = this.recordPolygonLayer.source;
  366. source.clear(true);
  367. const list = Array.isArray(records) ? records : [];
  368. const seenWkt = new Set();
  369. list.forEach((item) => {
  370. const wkt = getItemPolygon(item);
  371. if (!wkt || seenWkt.has(wkt)) return;
  372. seenWkt.add(wkt);
  373. try {
  374. source.addFeature(createRecordFeature(wkt, item, projection, tabKey));
  375. } catch (e) {
  376. console.warn("[FileMap] polygon parse failed", e);
  377. }
  378. });
  379. this.recordPolygonLayer.layer.changed();
  380. source.changed();
  381. this.fitView(renderToken);
  382. }
  383. setAlbumMarkers({ labels = [], photos = [] } = {}) {
  384. if (!this.kmap) {
  385. this._pendingMarkers = { labels, photos };
  386. return;
  387. }
  388. const source = this.albumMarkerLayer.source;
  389. source.clear(true);
  390. const projection = this.kmap.map.getView().getProjection();
  391. labels.forEach((item) => {
  392. if (!item?.name || item.longitude == null || item.latitude == null) return;
  393. const feature = new Feature({
  394. geometry: createPointGeometry(item.longitude, item.latitude, projection),
  395. });
  396. feature.setStyle(createZoneLabelStyle(item.name));
  397. source.addFeature(feature);
  398. });
  399. photos.forEach((item) => {
  400. if (item.longitude == null || item.latitude == null) return;
  401. const feature = new Feature({
  402. geometry: createPointGeometry(item.longitude, item.latitude, projection),
  403. });
  404. feature.setStyle(createPhotoMarkerStyle(null, item.count));
  405. source.addFeature(feature);
  406. loadImage(item.cover).then((img) => {
  407. if (!img) return;
  408. feature.setStyle(createPhotoMarkerStyle(img, item.count));
  409. feature.changed();
  410. });
  411. });
  412. this.albumMarkerLayer.layer.changed();
  413. source.changed();
  414. }
  415. fitView(renderToken, options = {}) {
  416. if (!this.kmap?.map) return;
  417. if (renderToken != null && renderToken !== this._renderToken) return;
  418. const map = this.kmap.map;
  419. map.updateSize();
  420. const features = this.recordPolygonLayer.source.getFeatures();
  421. const extent = resolveFitExtent(features, {
  422. bufferRatio: options.bufferRatio ?? 0.35,
  423. });
  424. if (!extent || isEmptyExtent(extent)) return;
  425. const size = map.getSize();
  426. if (!size || size[0] < 4 || size[1] < 4) {
  427. this._fitTimer = setTimeout(() => this.fitView(renderToken, options), 120);
  428. return;
  429. }
  430. const view = this.kmap.getView();
  431. view.cancelAnimations?.();
  432. const padding = options.padding || this.fitPadding || [100, 40, 40, 40];
  433. // Tab 切换时不用动画,避免连续 fit 互相打断导致视图停在上一 Tab 区域
  434. view.fit(extent, {
  435. size,
  436. duration: 0,
  437. padding,
  438. maxZoom: 19,
  439. });
  440. if ((view.getZoom() ?? 0) < 16) {
  441. view.setZoom(16);
  442. }
  443. }
  444. }
  445. export default FileMap;