|
@@ -13,26 +13,48 @@
|
|
|
<div class="locate-btn" @click="handleLocate">
|
|
<div class="locate-btn" @click="handleLocate">
|
|
|
<img class="locate-btn__icon" src="@/assets/img/map/map-icon.png" alt="" />
|
|
<img class="locate-btn__icon" src="@/assets/img/map/map-icon.png" alt="" />
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="item in zoneList"
|
|
|
|
|
+ :key="item.id"
|
|
|
|
|
+ :ref="(el) => setMarkerEl(item.id, el)"
|
|
|
|
|
+ class="zone-marker"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="zone-marker__label">{{ item.name }}</div>
|
|
|
|
|
+ <div class="zone-marker__thumb">
|
|
|
|
|
+ <img :src="item.cover" alt="" />
|
|
|
|
|
+ <span v-if="item.count" class="zone-marker__badge">{{ item.count }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="zone-marker__arrow"></div>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import { nextTick, onActivated, ref } from "vue";
|
|
|
|
|
|
|
+import { nextTick, onActivated, onBeforeUnmount, onMounted, ref } from "vue";
|
|
|
import { useStore } from "vuex";
|
|
import { useStore } from "vuex";
|
|
|
|
|
+import Overlay from "ol/Overlay";
|
|
|
import customHeader from "@/components/customHeader.vue";
|
|
import customHeader from "@/components/customHeader.vue";
|
|
|
import locationSearch from "@/components/pageComponents/locationSearch.vue";
|
|
import locationSearch from "@/components/pageComponents/locationSearch.vue";
|
|
|
import FileMap from "../fileMap";
|
|
import FileMap from "../fileMap";
|
|
|
import * as util from "@/common/ol_common.js";
|
|
import * as util from "@/common/ol_common.js";
|
|
|
|
|
+import { base_img_url2 } from "@/api/config";
|
|
|
import { useI18n } from "@/i18n";
|
|
import { useI18n } from "@/i18n";
|
|
|
|
|
|
|
|
const { t } = useI18n();
|
|
const { t } = useI18n();
|
|
|
const store = useStore();
|
|
const store = useStore();
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 常量 ----------
|
|
|
|
|
+const defaultCover = require("@/assets/img/agricultural/photo.png");
|
|
|
|
|
+const DEFAULT_CENTER = [113.6142086995688, 23.585836479509055];
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 页面状态 ----------
|
|
|
const mapContainer = ref(null);
|
|
const mapContainer = ref(null);
|
|
|
const fileMap = new FileMap();
|
|
const fileMap = new FileMap();
|
|
|
-
|
|
|
|
|
-const defaultCover = require("@/assets/img/home/banner.png");
|
|
|
|
|
-const DEFAULT_MAP_LOCATION = "POINT(113.6142086995688 23.585836479509055)";
|
|
|
|
|
|
|
+fileMap.fitPadding = [68, 50, 50, 50];
|
|
|
|
|
+const markerEls = {};
|
|
|
|
|
+const mapOverlays = [];
|
|
|
|
|
+const zoneList = ref([]);
|
|
|
|
|
|
|
|
const userLocation = ref(
|
|
const userLocation = ref(
|
|
|
store.state.home.miniUserLocation ||
|
|
store.state.home.miniUserLocation ||
|
|
@@ -40,125 +62,146 @@ const userLocation = ref(
|
|
|
"113.61702297075017,23.584863449735067"
|
|
"113.61702297075017,23.584863449735067"
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
-const MOCK_ZONES = [
|
|
|
|
|
- { id: 1, nameKey: "agriFile.zoneOne", dlng: -0.00115, dlat: 0.00055, delta: 0.00155 },
|
|
|
|
|
- { id: 2, nameKey: "agriFile.zoneTwo", dlng: 0.00105, dlat: -0.00095, delta: 0.00128 },
|
|
|
|
|
-];
|
|
|
|
|
-
|
|
|
|
|
-const MOCK_PHOTOS = [
|
|
|
|
|
- { id: 1, dlng: -0.00185, dlat: 0.00115 },
|
|
|
|
|
- { id: 2, dlng: 0.00155, dlat: 0.00075 },
|
|
|
|
|
- { id: 3, dlng: -0.00055, dlat: -0.00135 },
|
|
|
|
|
- { id: 4, dlng: 0.00135, dlat: -0.00075 },
|
|
|
|
|
- { id: 5, dlng: -0.00235, dlat: -0.00015 },
|
|
|
|
|
- { id: 6, dlng: 0.00015, dlat: 0.00155 },
|
|
|
|
|
- { id: 7, dlng: 0.00255, dlat: -0.00135 },
|
|
|
|
|
- { id: 8, dlng: -0.00015, dlat: -0.00195 },
|
|
|
|
|
-];
|
|
|
|
|
-
|
|
|
|
|
-function squarePolygonWkt(lng, lat, delta = 0.0014) {
|
|
|
|
|
- const ring = [
|
|
|
|
|
- [lng - delta, lat + delta * 0.7],
|
|
|
|
|
- [lng + delta * 0.35, lat + delta],
|
|
|
|
|
- [lng + delta, lat - delta * 0.2],
|
|
|
|
|
- [lng + delta * 0.15, lat - delta],
|
|
|
|
|
- [lng - delta * 0.85, lat - delta * 0.45],
|
|
|
|
|
- [lng - delta, lat + delta * 0.7],
|
|
|
|
|
- ];
|
|
|
|
|
- return `POLYGON((${ring.map((point) => point.join(" ")).join(", ")}))`;
|
|
|
|
|
|
|
+// ---------- 工具函数 ----------
|
|
|
|
|
+function getSelectedFarmId() {
|
|
|
|
|
+ const stored = localStorage.getItem("selectedFarmId");
|
|
|
|
|
+ if (stored != null && stored !== "") {
|
|
|
|
|
+ return Number(stored);
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const farm = JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
|
|
|
|
|
+ const id = farm.farm_id ?? farm.id;
|
|
|
|
|
+ return id != null && id !== "" ? Number(id) : null;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function getFarmData() {
|
|
|
|
|
|
|
+function parsePointCoordinates(pointWkt) {
|
|
|
|
|
+ if (!pointWkt || !/^POINT\s*\(/i.test(String(pointWkt).trim())) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
try {
|
|
try {
|
|
|
- return JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
|
|
|
|
|
|
|
+ const coord = util.wktCastGeom(pointWkt).getFirstCoordinate();
|
|
|
|
|
+ if (!coord || coord.length < 2) return null;
|
|
|
|
|
+ return { longitude: coord[0], latitude: coord[1] };
|
|
|
} catch {
|
|
} catch {
|
|
|
- return {};
|
|
|
|
|
|
|
+ return null;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function isPointWkt(value) {
|
|
|
|
|
- return typeof value === "string" && /^POINT\s*\(/i.test(value.trim());
|
|
|
|
|
|
|
+function resolveZoneCoverUrl(path) {
|
|
|
|
|
+ if (!path) return defaultCover;
|
|
|
|
|
+ const text = String(path).trim();
|
|
|
|
|
+ if (!text) return defaultCover;
|
|
|
|
|
+ if (/^https?:\/\//i.test(text)) return text;
|
|
|
|
|
+ return base_img_url2 + text.replace(/^\//, "");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function wkbHexToPointWkt(hex) {
|
|
|
|
|
- const text = String(hex || "").trim();
|
|
|
|
|
- if (!/^[0-9a-fA-F]+$/.test(text) || text.length < 42) return null;
|
|
|
|
|
- const bytes = new Uint8Array(text.length / 2);
|
|
|
|
|
- for (let i = 0; i < bytes.length; i++) {
|
|
|
|
|
- bytes[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
|
|
|
- }
|
|
|
|
|
- const view = new DataView(bytes.buffer);
|
|
|
|
|
- const little = view.getUint8(0) === 1;
|
|
|
|
|
- let type = view.getUint32(1, little);
|
|
|
|
|
- const hasSrid = (type & 0x20000000) !== 0;
|
|
|
|
|
- type &= 0xff;
|
|
|
|
|
- if (type !== 1) return null;
|
|
|
|
|
- let offset = 5;
|
|
|
|
|
- if (hasSrid) offset += 4;
|
|
|
|
|
- if (offset + 16 > bytes.length) return null;
|
|
|
|
|
- const lng = view.getFloat64(offset, little);
|
|
|
|
|
- const lat = view.getFloat64(offset + 8, little);
|
|
|
|
|
- if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
|
|
|
|
|
- return `POINT(${lng} ${lat})`;
|
|
|
|
|
|
|
+function mapZoneItem(zone) {
|
|
|
|
|
+ const coords = parsePointCoordinates(zone?.point);
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: zone.zone_id,
|
|
|
|
|
+ zoneId: zone.zone_id,
|
|
|
|
|
+ zone_id: zone.zone_id,
|
|
|
|
|
+ name: zone.zone_name || "",
|
|
|
|
|
+ count: zone.image_count ?? 0,
|
|
|
|
|
+ cover: resolveZoneCoverUrl(zone.cover_url),
|
|
|
|
|
+ longitude: coords?.longitude,
|
|
|
|
|
+ latitude: coords?.latitude,
|
|
|
|
|
+ point: zone.point,
|
|
|
|
|
+ };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function toPointWkt(value) {
|
|
|
|
|
- if (!value || typeof value !== "string") return null;
|
|
|
|
|
- if (isPointWkt(value)) return value.trim();
|
|
|
|
|
- return wkbHexToPointWkt(value);
|
|
|
|
|
-}
|
|
|
|
|
|
|
+const setMarkerEl = (id, el) => {
|
|
|
|
|
+ if (el) markerEls[id] = el;
|
|
|
|
|
+ else delete markerEls[id];
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
-function getFarmMapLocation() {
|
|
|
|
|
- const farmData = getFarmData();
|
|
|
|
|
- const candidates = [farmData.wkt, farmData.geom_wkt, farmData.farm_location];
|
|
|
|
|
- for (const item of candidates) {
|
|
|
|
|
- const pointWkt = toPointWkt(item);
|
|
|
|
|
- if (pointWkt) return pointWkt;
|
|
|
|
|
|
|
+const getFarmCenter = () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const farm = JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
|
|
|
|
|
+ const wkt = farm.wkt || farm.geom_wkt || farm.farm_location;
|
|
|
|
|
+ if (typeof wkt === "string" && /^POINT\s*\(/i.test(wkt.trim())) {
|
|
|
|
|
+ return util.wktCastGeom(wkt).getFirstCoordinate();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore
|
|
|
}
|
|
}
|
|
|
- return DEFAULT_MAP_LOCATION;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ const firstZone = zoneList.value.find((z) => z.longitude != null && z.latitude != null);
|
|
|
|
|
+ if (firstZone) {
|
|
|
|
|
+ return [firstZone.longitude, firstZone.latitude];
|
|
|
|
|
+ }
|
|
|
|
|
+ return DEFAULT_CENTER;
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
-function getDefaultMapLocation() {
|
|
|
|
|
- return (
|
|
|
|
|
- toPointWkt(localStorage.getItem("MINI_USER_LOCATION_POINT")) ||
|
|
|
|
|
- toPointWkt(store.state.home.miniUserLocationPoint) ||
|
|
|
|
|
- getFarmMapLocation()
|
|
|
|
|
- );
|
|
|
|
|
-}
|
|
|
|
|
|
|
+// ---------- 业务逻辑:地图 / 区域 ----------
|
|
|
|
|
+const clearMapOverlays = () => {
|
|
|
|
|
+ if (!fileMap.kmap?.map) {
|
|
|
|
|
+ mapOverlays.length = 0;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ mapOverlays.forEach((overlay) => fileMap.kmap.map.removeOverlay(overlay));
|
|
|
|
|
+ mapOverlays.length = 0;
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
-function getDefaultMapCoordinate() {
|
|
|
|
|
- return util.wktCastGeom(getDefaultMapLocation()).getFirstCoordinate();
|
|
|
|
|
-}
|
|
|
|
|
|
|
+const bindZoneOverlays = () => {
|
|
|
|
|
+ if (!fileMap.kmap?.map) return;
|
|
|
|
|
+ clearMapOverlays();
|
|
|
|
|
+ zoneList.value.forEach((zone) => {
|
|
|
|
|
+ const el = markerEls[zone.id];
|
|
|
|
|
+ if (!el || zone.longitude == null || zone.latitude == null) return;
|
|
|
|
|
+ const overlay = new Overlay({
|
|
|
|
|
+ element: el,
|
|
|
|
|
+ position: [zone.longitude, zone.latitude],
|
|
|
|
|
+ positioning: "bottom-center",
|
|
|
|
|
+ stopEvent: false,
|
|
|
|
|
+ });
|
|
|
|
|
+ fileMap.kmap.map.addOverlay(overlay);
|
|
|
|
|
+ mapOverlays.push(overlay);
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
-const loadAlbumLayers = () => {
|
|
|
|
|
- const [lng, lat] = getDefaultMapCoordinate();
|
|
|
|
|
- const labels = MOCK_ZONES.map((item) => ({
|
|
|
|
|
- name: t(item.nameKey),
|
|
|
|
|
- longitude: lng + item.dlng,
|
|
|
|
|
- latitude: lat + item.dlat,
|
|
|
|
|
- }));
|
|
|
|
|
- const photos = MOCK_PHOTOS.map((item) => ({
|
|
|
|
|
- count: 2,
|
|
|
|
|
- cover: defaultCover,
|
|
|
|
|
- longitude: lng + item.dlng,
|
|
|
|
|
- latitude: lat + item.dlat,
|
|
|
|
|
- }));
|
|
|
|
|
- const zoneRecords = MOCK_ZONES.map((item) => ({
|
|
|
|
|
- zone_name: "",
|
|
|
|
|
- polygon: squarePolygonWkt(lng + item.dlng, lat + item.dlat, item.delta),
|
|
|
|
|
- }));
|
|
|
|
|
- fileMap.setRecordPolygons(zoneRecords, "album");
|
|
|
|
|
- fileMap.setAlbumMarkers({ labels, photos });
|
|
|
|
|
|
|
+const fitZonesOnMap = () => {
|
|
|
|
|
+ const records = zoneList.value
|
|
|
|
|
+ .filter((z) => z.longitude != null && z.latitude != null)
|
|
|
|
|
+ .map((z) => ({
|
|
|
|
|
+ zone_name: "",
|
|
|
|
|
+ polygon: `POINT(${z.longitude} ${z.latitude})`,
|
|
|
|
|
+ }));
|
|
|
|
|
+ if (!records.length) return;
|
|
|
|
|
+ fileMap.setRecordPolygons(records, "album");
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+async function fetchZoneList() {
|
|
|
|
|
+ const farmId = getSelectedFarmId();
|
|
|
|
|
+ if (!farmId) {
|
|
|
|
|
+ zoneList.value = [];
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await VE_API.questionnaire.getZones({ farm_id: farmId });
|
|
|
|
|
+ const zones = res?.data?.zones;
|
|
|
|
|
+ zoneList.value = Array.isArray(zones) ? zones.map(mapZoneItem) : [];
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.warn("[albumMap] getZones failed", e);
|
|
|
|
|
+ zoneList.value = [];
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
const initAlbumMap = async () => {
|
|
const initAlbumMap = async () => {
|
|
|
await nextTick();
|
|
await nextTick();
|
|
|
if (!mapContainer.value) return;
|
|
if (!mapContainer.value) return;
|
|
|
- fileMap.initMap(getDefaultMapLocation(), mapContainer.value);
|
|
|
|
|
- loadAlbumLayers();
|
|
|
|
|
|
|
+ await fetchZoneList();
|
|
|
|
|
+ const center = getFarmCenter();
|
|
|
|
|
+ fileMap.initMap(`POINT(${center[0]} ${center[1]})`, mapContainer.value);
|
|
|
|
|
+ await nextTick();
|
|
|
|
|
+ bindZoneOverlays();
|
|
|
fileMap.kmap?.map?.updateSize?.();
|
|
fileMap.kmap?.map?.updateSize?.();
|
|
|
|
|
+ fitZonesOnMap();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+// ---------- 事件处理 ----------
|
|
|
const handleLocationChange = (payload) => {
|
|
const handleLocationChange = (payload) => {
|
|
|
if (!payload?.coordinateArray || !fileMap.kmap) return;
|
|
if (!payload?.coordinateArray || !fileMap.kmap) return;
|
|
|
fileMap.kmap.getView().animate({
|
|
fileMap.kmap.getView().animate({
|
|
@@ -170,16 +213,18 @@ const handleLocationChange = (payload) => {
|
|
|
|
|
|
|
|
const handleLocate = () => {
|
|
const handleLocate = () => {
|
|
|
if (!fileMap.kmap) return;
|
|
if (!fileMap.kmap) return;
|
|
|
|
|
+ const center = getFarmCenter();
|
|
|
fileMap.kmap.getView().animate({
|
|
fileMap.kmap.getView().animate({
|
|
|
- center: getDefaultMapCoordinate(),
|
|
|
|
|
|
|
+ center,
|
|
|
zoom: 16,
|
|
zoom: 16,
|
|
|
duration: 0,
|
|
duration: 0,
|
|
|
});
|
|
});
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-onActivated(() => {
|
|
|
|
|
- initAlbumMap();
|
|
|
|
|
-});
|
|
|
|
|
|
|
+// ---------- 生命周期 ----------
|
|
|
|
|
+onMounted(initAlbumMap);
|
|
|
|
|
+onActivated(initAlbumMap);
|
|
|
|
|
+onBeforeUnmount(clearMapOverlays);
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
<style lang="scss" scoped>
|
|
@@ -204,6 +249,59 @@ onActivated(() => {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.zone-marker {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+
|
|
|
|
|
+ &__label {
|
|
|
|
|
+ padding: 3px 14px;
|
|
|
|
|
+ border-radius: 25px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ color: #0a0a0a;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ &__thumb {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ width: 42px;
|
|
|
|
|
+ height: 42px;
|
|
|
|
|
+ margin-top: 8px;
|
|
|
|
|
+
|
|
|
|
|
+ img {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ object-fit: cover;
|
|
|
|
|
+ border: 2px solid #fff;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ &__badge {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: -6px;
|
|
|
|
|
+ right: -6px;
|
|
|
|
|
+ min-width: 18px;
|
|
|
|
|
+ height: 18px;
|
|
|
|
|
+ line-height: 16px;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ color: #0a0a0a;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ &__arrow {
|
|
|
|
|
+ width: 0;
|
|
|
|
|
+ height: 0;
|
|
|
|
|
+ border-left: 5px solid transparent;
|
|
|
|
|
+ border-right: 5px solid transparent;
|
|
|
|
|
+ border-top: 6px solid #fff;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.search-bar {
|
|
.search-bar {
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
top: 12px;
|
|
top: 12px;
|