fileMap.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  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. return new Icon({
  78. src: canvas.toDataURL(),
  79. scale: 1 / dpr,
  80. anchor,
  81. anchorXUnits: "fraction",
  82. anchorYUnits: "fraction",
  83. });
  84. }
  85. function createZoneLabelStyle(name) {
  86. const text = String(name || "");
  87. const font = "13px sans-serif";
  88. const padX = 10;
  89. const padY = 4;
  90. const measure = document.createElement("canvas").getContext("2d");
  91. measure.font = font;
  92. const textWidth = Math.ceil(measure.measureText(text).width);
  93. const boxW = textWidth + padX * 2;
  94. const boxH = 21;
  95. const { canvas, ctx, dpr } = createHiDpiCanvas(boxW + 8, boxH + 8);
  96. const x = 4;
  97. const y = 4;
  98. ctx.shadowColor = "rgba(0, 0, 0, 0.12)";
  99. ctx.shadowBlur = 4;
  100. ctx.shadowOffsetY = 1;
  101. roundRectPath(ctx, x, y, boxW, boxH, 4);
  102. ctx.fillStyle = "#ffffff";
  103. ctx.fill();
  104. ctx.shadowColor = "transparent";
  105. ctx.fillStyle = "#1F1F1F";
  106. ctx.font = font;
  107. ctx.textAlign = "center";
  108. ctx.textBaseline = "middle";
  109. ctx.fillText(text, x + boxW / 2, y + boxH / 2);
  110. return new Style({
  111. image: iconFromCanvas(canvas, dpr, [0.5, 0.5]),
  112. });
  113. }
  114. function drawCoverImage(ctx, img, x, y, size) {
  115. const scale = Math.max(size / img.width, size / img.height);
  116. const dw = img.width * scale;
  117. const dh = img.height * scale;
  118. ctx.drawImage(img, x + (size - dw) / 2, y + (size - dh) / 2, dw, dh);
  119. }
  120. function createPhotoMarkerStyle(img, count) {
  121. const thumb = 42;
  122. const arrowH = 6;
  123. const arrowW = 10;
  124. const badgeH = 16;
  125. const countText = String(count ?? "");
  126. const badgeW = Math.max(16, 8 + countText.length * 7);
  127. const cssW = thumb + 12;
  128. const cssH = 6 + thumb + arrowH;
  129. const { canvas, ctx, dpr } = createHiDpiCanvas(cssW, cssH);
  130. const thumbX = (cssW - thumb) / 2;
  131. const thumbY = 6;
  132. ctx.save();
  133. roundRectPath(ctx, thumbX, thumbY, thumb, thumb, 6);
  134. ctx.clip();
  135. if (img) {
  136. drawCoverImage(ctx, img, thumbX, thumbY, thumb);
  137. } else {
  138. ctx.fillStyle = "#d9d9d9";
  139. ctx.fillRect(thumbX, thumbY, thumb, thumb);
  140. }
  141. ctx.restore();
  142. roundRectPath(ctx, thumbX, thumbY, thumb, thumb, 6);
  143. ctx.strokeStyle = "#ffffff";
  144. ctx.lineWidth = 2;
  145. ctx.stroke();
  146. const ax = thumbX + thumb / 2;
  147. const ay = thumbY + thumb - 1;
  148. ctx.beginPath();
  149. ctx.moveTo(ax - arrowW / 2, ay);
  150. ctx.lineTo(ax + arrowW / 2, ay);
  151. ctx.lineTo(ax, ay + arrowH);
  152. ctx.closePath();
  153. ctx.fillStyle = "#ffffff";
  154. ctx.fill();
  155. const bx = thumbX + thumb - badgeW + 4;
  156. const by = thumbY - 6;
  157. roundRectPath(ctx, bx, by, badgeW, badgeH, 8);
  158. ctx.fillStyle = "#ffffff";
  159. ctx.fill();
  160. ctx.fillStyle = "#1F1F1F";
  161. ctx.font = "10px sans-serif";
  162. ctx.textAlign = "center";
  163. ctx.textBaseline = "middle";
  164. ctx.fillText(countText, bx + badgeW / 2, by + badgeH / 2 + 0.5);
  165. return new Style({
  166. image: iconFromCanvas(canvas, dpr, [0.5, 1]),
  167. });
  168. }
  169. function loadImage(src) {
  170. return new Promise((resolve) => {
  171. if (!src) {
  172. resolve(null);
  173. return;
  174. }
  175. const img = new Image();
  176. if (/^https?:\/\//i.test(String(src))) {
  177. img.crossOrigin = "anonymous";
  178. }
  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) {
  223. const extents = getUniqueExtents(features);
  224. if (!extents.length) return null;
  225. const fitOne = (e) => {
  226. const span = Math.max(getWidth(e), getHeight(e), 0.00001);
  227. return bufferExtent(e, span * 0.35);
  228. };
  229. if (extents.length === 1) return fitOne(extents[0]);
  230. const union = createEmpty();
  231. extents.forEach((e) => extendExtent(union, e));
  232. if (getWidth(union) > 0.15 || getHeight(union) > 0.15) {
  233. return fitOne(extents[0]);
  234. }
  235. return bufferExtent(union, Math.max(getWidth(union), getHeight(union), 0.00001) * 0.12);
  236. }
  237. export function recordsToCenterPoint(records) {
  238. const wkt = getItemPolygon(records?.[0]);
  239. if (!wkt) return null;
  240. try {
  241. const c = util.wktCastGeom(wkt).getFirstCoordinate();
  242. return `POINT(${c[0]} ${c[1]})`;
  243. } catch {
  244. return null;
  245. }
  246. }
  247. class FileMap {
  248. constructor() {
  249. this._pending = null;
  250. this._pendingMarkers = null;
  251. this._fitTimer = null;
  252. this._renderToken = 0;
  253. this.baseImageLayers = [];
  254. this.baseImageVisible = false;
  255. this.currentFarmId = null;
  256. const vectorStyle = new KMap.VectorStyle();
  257. this.recordPolygonLayer = new KMap.VectorLayer("fileRecordPolygonLayer", 1000, {
  258. minZoom: 8,
  259. maxZoom: 22,
  260. source: new VectorSource({}),
  261. style: (f) => {
  262. const colors = RECORD_TYPE_STYLE[f.get("styleType")] || RECORD_TYPE_STYLE.zone;
  263. const polygonStyle = vectorStyle.getPolygonStyle(colors.fill, colors.stroke, 2);
  264. const label = f.get("zone_name");
  265. if (!label || f.get("styleType") === "album") return [polygonStyle];
  266. return [
  267. polygonStyle,
  268. new Style({
  269. text: new Text({
  270. font: "12px sans-serif",
  271. text: label,
  272. fill: new Fill({ color: "#fff" }),
  273. stroke: new Stroke({ color: "#000", width: 0.5 }),
  274. }),
  275. }),
  276. ];
  277. },
  278. });
  279. this.albumMarkerLayer = new KMap.VectorLayer("fileAlbumMarkerLayer", 1001, {
  280. minZoom: 8,
  281. maxZoom: 22,
  282. source: new VectorSource({}),
  283. });
  284. }
  285. initBaseImageLayers() {
  286. FARM_BASE_IMAGE_LAYERS.forEach((item) => {
  287. const imgLayer = new StaticImgLayer(
  288. item.url,
  289. {
  290. extent: item.extent,
  291. minZoom: 8,
  292. maxZoom: 22,
  293. opacity: 0.4,
  294. },
  295. 4,
  296. this.kmap,
  297. );
  298. this.baseImageLayers.push({ farmId: item.farmId, layer: imgLayer });
  299. });
  300. this.applyBaseImageVisibility();
  301. }
  302. applyBaseImageVisibility() {
  303. const canShow = this.baseImageVisible && hasFarmBaseImage(this.currentFarmId);
  304. this.baseImageLayers.forEach(({ farmId, layer }) => {
  305. const shouldShow = canShow && farmId === this.currentFarmId;
  306. if (shouldShow) {
  307. layer.show();
  308. } else {
  309. layer.hide();
  310. }
  311. });
  312. }
  313. showBaseImageByFarmId(farmId) {
  314. this.currentFarmId = farmId != null && farmId !== "" ? Number(farmId) : null;
  315. if (!hasFarmBaseImage(this.currentFarmId)) {
  316. this.baseImageVisible = false;
  317. }
  318. this.applyBaseImageVisibility();
  319. }
  320. toggleBaseImageLayers(visible = !this.baseImageVisible) {
  321. this.baseImageVisible = visible && hasFarmBaseImage(this.currentFarmId);
  322. this.applyBaseImageVisibility();
  323. }
  324. initMap(centerWkt, target) {
  325. if (!target) return;
  326. const center = util.wktCastGeom(centerWkt || DEFAULT_CENTER).getFirstCoordinate();
  327. if (this.kmap?.map) {
  328. this.kmap.map.setTarget(target);
  329. this.kmap.map.updateSize();
  330. this.flushPending();
  331. return;
  332. }
  333. this.kmap = new KMap.Map(target, 16, center[0], center[1], null, 8, 22);
  334. this.kmap.addXYZLayer(config.base_img_url3 + "map/lby/{z}/{x}/{y}.png", { minZoom: 8, maxZoom: 22 }, 2);
  335. this.initBaseImageLayers();
  336. this.kmap.addLayer(this.recordPolygonLayer.layer);
  337. this.kmap.addLayer(this.albumMarkerLayer.layer);
  338. this.flushPending();
  339. }
  340. flushPending() {
  341. if (this._pending) {
  342. const pending = this._pending;
  343. this._pending = null;
  344. this.setRecordPolygons(pending.records, pending.tabKey);
  345. }
  346. if (this._pendingMarkers) {
  347. const pending = this._pendingMarkers;
  348. this._pendingMarkers = null;
  349. this.setAlbumMarkers(pending);
  350. }
  351. }
  352. setRecordPolygons(records, tabKey = "phenology") {
  353. if (!this.kmap) {
  354. this._pending = { records, tabKey };
  355. return;
  356. }
  357. if (this._fitTimer) {
  358. clearTimeout(this._fitTimer);
  359. this._fitTimer = null;
  360. }
  361. const renderToken = ++this._renderToken;
  362. const projection = this.kmap.map.getView().getProjection();
  363. const source = this.recordPolygonLayer.source;
  364. source.clear(true);
  365. const list = Array.isArray(records) ? records : [];
  366. const seenWkt = new Set();
  367. list.forEach((item) => {
  368. const wkt = getItemPolygon(item);
  369. if (!wkt || seenWkt.has(wkt)) return;
  370. seenWkt.add(wkt);
  371. try {
  372. source.addFeature(createRecordFeature(wkt, item, projection, tabKey));
  373. } catch (e) {
  374. console.warn("[FileMap] polygon parse failed", e);
  375. }
  376. });
  377. this.recordPolygonLayer.layer.changed();
  378. source.changed();
  379. this.fitView(renderToken);
  380. }
  381. setAlbumMarkers({ labels = [], photos = [] } = {}) {
  382. if (!this.kmap) {
  383. this._pendingMarkers = { labels, photos };
  384. return;
  385. }
  386. const source = this.albumMarkerLayer.source;
  387. source.clear(true);
  388. const projection = this.kmap.map.getView().getProjection();
  389. labels.forEach((item) => {
  390. if (!item?.name || item.longitude == null || item.latitude == null) return;
  391. const feature = new Feature({
  392. geometry: createPointGeometry(item.longitude, item.latitude, projection),
  393. });
  394. feature.setStyle(createZoneLabelStyle(item.name));
  395. source.addFeature(feature);
  396. });
  397. photos.forEach((item) => {
  398. if (item.longitude == null || item.latitude == null) return;
  399. const feature = new Feature({
  400. geometry: createPointGeometry(item.longitude, item.latitude, projection),
  401. });
  402. feature.setStyle(createPhotoMarkerStyle(null, item.count));
  403. source.addFeature(feature);
  404. loadImage(item.cover).then((img) => {
  405. feature.setStyle(createPhotoMarkerStyle(img, item.count));
  406. });
  407. });
  408. this.albumMarkerLayer.layer.changed();
  409. source.changed();
  410. }
  411. fitView(renderToken) {
  412. if (!this.kmap?.map) return;
  413. if (renderToken != null && renderToken !== this._renderToken) return;
  414. const map = this.kmap.map;
  415. map.updateSize();
  416. const features = this.recordPolygonLayer.source.getFeatures();
  417. const extent = resolveFitExtent(features);
  418. if (!extent || isEmptyExtent(extent)) return;
  419. const size = map.getSize();
  420. if (!size || size[0] < 4 || size[1] < 4) {
  421. this._fitTimer = setTimeout(() => this.fitView(renderToken), 120);
  422. return;
  423. }
  424. const view = this.kmap.getView();
  425. view.cancelAnimations?.();
  426. // Tab 切换时不用动画,避免连续 fit 互相打断导致视图停在上一 Tab 区域
  427. view.fit(extent, {
  428. size,
  429. duration: 0,
  430. padding: [100, 40, 40, 40],
  431. maxZoom: 19,
  432. });
  433. if ((view.getZoom() ?? 0) < 16) {
  434. view.setZoom(16);
  435. }
  436. }
  437. }
  438. export default FileMap;