diff --git a/.idea/fileTemplates/[项目专用]常规组件.vue b/.idea/fileTemplates/[项目专用]常规组件.vue new file mode 100644 index 0000000..38c3ef2 --- /dev/null +++ b/.idea/fileTemplates/[项目专用]常规组件.vue @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/src/assets/login_bg.png b/src/assets/login_bg.png new file mode 100644 index 0000000..313c873 Binary files /dev/null and b/src/assets/login_bg.png differ diff --git a/src/components/BasePanel.vue b/src/components/BasePanel.vue new file mode 100644 index 0000000..32d4973 --- /dev/null +++ b/src/components/BasePanel.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/src/components/ImageUploader.vue b/src/components/ImageUploader.vue new file mode 100644 index 0000000..3f870e9 --- /dev/null +++ b/src/components/ImageUploader.vue @@ -0,0 +1,92 @@ + + + + diff --git a/src/config/urls.js b/src/config/urls.js index 6244c5f..5a3e97a 100644 --- a/src/config/urls.js +++ b/src/config/urls.js @@ -2,8 +2,35 @@ * 接口地址列表 */ +import { buildURL } from '@/utils/helpers'; + const { APP_DEVELOPMENT_BASE_URL, APP_PRODUCTION_BASE_URL, MODE } = import.meta.env; // 获取环境变量 export const { [MODE]: BASE_URL = APP_DEVELOPMENT_BASE_URL } = { development: APP_DEVELOPMENT_BASE_URL, production: APP_PRODUCTION_BASE_URL }; // 声明变量 BASE_URL 并赋值 +// 上传文件 +export const REQUEST_UPLOAD_FILE = (subject) => buildURL(`${BASE_URL}/fileServ/v1/files/{fileType}`, subject); + // 登录 -export const EXAMPLE_API = `${BASE_URL}xxxxxxxx`; +export const LOGIN_WITH_PASSWORD = `${BASE_URL}/userServ/v1/login`; + +// 禁飞区管理 +export const GET_NO_FLY_ZONE_LIST = `${BASE_URL}/mainServ/v1/noFlyZones`; +export const GET_NO_FLY_ZONE_DETAIL = `${BASE_URL}/mainServ/v1/noFlyZones/{id}`; +export const CREATE_NO_FLY_ZONE = `${BASE_URL}/mainServ/v1/noFlyZones`; +export const UPDATE_NO_FLY_ZONE = `${BASE_URL}/mainServ/v1/noFlyZones/{id}`; +export const DELETE_NO_FLY_ZONE = `${BASE_URL}/mainServ/v1/noFlyZones/{id}`; +export const UPDATE_NO_FLY_ZONE_DISABLE = `${BASE_URL}/mainServ/v1/noFlyZones/{id}/disable`; + +// 制造商管理 +export const GET_MANUFACTURER_LIST = `${BASE_URL}/mainServ/v1/manufacturers`; +export const CREATE_MANUFACTURER = `${BASE_URL}/mainServ/v1/manufacturers`; +export const UPDATE_MANUFACTURER = `${BASE_URL}/mainServ/v1/manufacturers/{id}`; +export const DELETE_MANUFACTURER = `${BASE_URL}/mainServ/v1/manufacturers/{id}`; +export const UPDATE_MANUFACTURER_DISABLE = `${BASE_URL}/mainServ/v1/manufacturers/{id}/disable`; + +// 制造商管理 +export const GET_REGULATOR_LIST = `${BASE_URL}/userServ/v1/regulators`; +export const CREATE_REGULATOR = `${BASE_URL}/userServ/v1/regulator`; +// export const UPDATE_REGULATOR = `${BASE_URL}/mainServ/v1/manufacturers/{id}`; +// export const DELETE_REGULATOR = `${BASE_URL}/mainServ/v1/manufacturers/{id}`; +// export const UPDATE_MANUFACTURER_DISABLE = `${BASE_URL}/mainServ/v1/manufacturers/{id}/disable`; diff --git a/src/core/EventDispatcher.js b/src/core/EventDispatcher.js new file mode 100644 index 0000000..15168bf --- /dev/null +++ b/src/core/EventDispatcher.js @@ -0,0 +1,66 @@ +/** + * 事件调度类 + */ + +class EventDispatcher { + // 支持的事件名 + _supportedEventNames = ['click']; + + // 事件池 + _eventPool = {}; + + constructor(supportedEventNames = []) { + this._supportedEventNames = [ + ...this._supportedEventNames, + ...supportedEventNames, + ]; + } + + on(eventName, eventHandler) { + if (!this._supportedEventNames.includes(eventName)) return; + + if (eventName in this._eventPool) { + if (!this._eventPool[eventName].includes(eventHandler)) { + this._eventPool[eventName].push(eventHandler); + } + } else { + this._eventPool[eventName] = [eventHandler]; + } + } + + once(eventName, eventHandler) { + if (!this._supportedEventNames.includes(eventName)) return; + + const handler = payload => { + eventHandler(payload); + this.off(eventName, handler); + }; + this.on(eventName, handler); + } + + off(eventName, eventHandler) { + if (!this._supportedEventNames.includes(eventName)) return; + + if (eventName in this._eventPool) { + if (!eventHandler) { + delete this._eventPool[eventName]; + return; + } + const index = this._eventPool[eventName].indexOf(eventHandler); + if (index >= 0) { + this._eventPool[eventName].splice(index, 1); + } + } + } + + _trigger(eventName, payload = {}) { + if (!this._supportedEventNames.includes(eventName)) return; + if (!(eventName in this._eventPool)) return; + + (this._eventPool[eventName] || []).forEach(eventHandler => { + eventHandler(payload); + }); + } +} + +export default EventDispatcher; diff --git a/src/core/ShapeCreator.js b/src/core/ShapeCreator.js new file mode 100644 index 0000000..1e0dc8b --- /dev/null +++ b/src/core/ShapeCreator.js @@ -0,0 +1,401 @@ +/** + * 绘制多边形 + */ +import { action, computed, makeObservable, observable, reaction, values } from 'mobx'; +import mapbox from 'mapbox-gl'; +import * as turf from '@turf/turf'; +import EventDispatcher from './EventDispatcher'; + +// eslint-disable-next-line max-len +const aimCursor = 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsSAAALEgHS3X78AAADcElEQVRYhc1XTUgbURD+sgaDGEjWQ7EUQaFnydVLSW49SaC5SWgCKvRU9SxUoWdNT4UqpCJBChbFkxTqhl68Rs+CopRKwayr1RAxu+V7fa/YzebHWIwDE3b3zXwzb2bevInPcRy0SCkAL6XqEoCPrcDcxQEjl8td82FkZMQPIHbvDsRif2wahoFWHdBatf6/6DYOBAEkTNNc4aY91g25lpCyTVFTKbAsazwQCCSurq782WzWt76+jkKhgNPTU7EeDocRjUYFp9Npp7Oz87pcLq+GQqEPjbAbORC8uLh4b9v2k7m5OV8mkxFG+/v7hbF8Pi+E1PPBwYFwZmJiAlNTU46mad+7u7tfAfhV0wIdqMFBy7Jyh4eHW5FIhCE3UqmUUSgUDMdxBEejUcHqnWuUoSx1qEsMYtWyU7MGuHPLsh4PDg76uDPuMJvNoq+v7xuAMQ+VMa5RRkWDusQgVi07ng4w5wz78PCwj+8EHBoaKgOY7OnpeQNgDzL3ZEl7cm2Ssio9xCAWMZtNQbBUKn2ZmZkRoczn80a5XN50h7FSqXw6Pz//SuazG4M61CUGsYjplQovBxKWZW2Fw2GRT5nfiIdc7+Xl5TSZzx7r1BEYxCImsRs6UCwWVzKZjPCcRXVycjJbp1DrMnWJQSxiEtst73UMRYtlEe3v70MW3F6tImpATwEsDAwMIBKJYG1tDe6W7XfdaoLYZOLxuHpd4I9t2z81TXsN4LiB0V7btt9pmvZIfWCfYPNSG7whu8QIiFttcXHRr76ygtlsyJDVvry8bHd0dBhdXV1v61kvlUrTlUollkwmNdUpGU0yHVE0Ojp6zVu07ZcRI1CVAl3XRQrYVBTdJQXpdFqkwDRNt+xS24uwKgWmaR5z93RgZ2eHxzLZonGhSwyVf2JXCT24RvQQWjHv/PGzszNxDdN7djPpxM1IGPF4XLCM0t+dU5Y61CUGsYjZ9HXMSYbDxMbGhqhQ5m97ezsAYL5YLM7K4hLDiTrr/CbX5imrzjwxiFVrOqrZBzjJhEKhH7u7u46agHicjo6Onqnu6KIFrlGGstShLjHkVORJbR/JWhpKaYx8H0PpP9EA8Nw0zRe6rvfyg+uPiTjnuq5/BrBZdxC9Qf5mhCQRcFXX9VX57v5vEGMLvy21/TK6TQSqiFfqXXHa+/ccwG/SPyXk58r5VgAAAABJRU5ErkJggg==") 16 16'; + +class ShapeCreator extends EventDispatcher { + _topic = null; + + // 地图实例 + _map = null; + + // 构成形状的点坐标 + _coordinates = []; + + // 操作控制点 + _markers = []; + + // 形状是否扭结 + _isKinked = false; + + // 当前激活的marker索引 + _activeMarkerIndex = -1; + + // 操控点提示 + _markerTooltip = (new mapbox.Popup({ closeButton: false, offset: 6, className: 'shape-editor-popup' })).setHTML('按住可拖拽控制点
点击可微调控制点
点击第一个控制点可闭合形状'); + + // 闭合形状提示 + _completeTooltip = (new mapbox.Popup({ closeButton: false, offset: 6, className: 'shape-editor-popup' })).setHTML('按住可拖拽控制点
点击可微调控制点,或闭合形状'); + + // 坐标编辑器 + _coordinatePopupEditor = new mapbox.Popup({ offset: 6, className: 'shape-editor-popup' }); + + // 鼠标指针 + _cursor = `${aimCursor}, crosshair`; + + // 视觉选项 + _defaultStyle = { + fillColor: 'rgba(0, 0, 255, 0.2)', + edgeColor: 'rgba(0, 0, 255, 0.5)', + markerFillColor: 'rgba(0, 0, 255, 1)', + markerEdgeColor: 'rgba(255, 255, 255, 1)', + }; + + _style = {}; + + get _sourceId() { + return { + FILL: `${this._topic}-shape-creator-fill-source`, + EDGE: `${this._topic}-shape-creator-edge-source`, + }; + } + + get _layerId() { + return { + FILL: `${this._topic}-shape-creator-fill-layer`, + EDGE: `${this._topic}-shape-creator-edge-layer`, + }; + } + + // 形状边线 + get _shapeEdgeFeature() { + if (this._coordinates.length < 2) { + return turf.feature({}); + } + return turf.lineString(this._coordinates); + } + + // 形状填充 + get _shapeFillFeature() { + if (this._coordinates.length < 3) { + return turf.feature({}); + } + return turf.lineToPolygon(turf.lineString(this._coordinates)); + } + + constructor(topic, mapInstance = null) { + super(['shape_kinked', 'kink_recovered', 'completed']); + this._topic = topic; + if (mapInstance) this.setMap(mapInstance); + + this.initStyle(); + this._initCoordinateEditor(); + + makeObservable(this, { + _topic: observable, + _coordinates: observable, + _sourceId: computed, + _layerId: computed, + _shapeEdgeFeature: computed, + _shapeFillFeature: computed, + _appendToCoordinates: action, + _updateCoordinates: action, + clear: action, + }); + + reaction(() => this._coordinates, () => { + this._render(); + this._updateCoordinateEditor(); + }); + } + + // 初始化视觉样式 + initStyle(style = {}) { + this._style = { + ...this._defaultStyle, + ...style, + }; + } + + setMap(mapInstance) { + if (this._map === mapInstance) return; + if (!(mapInstance instanceof mapbox.Map)) { + throw new Error('必须传入一个mapbox地图实例'); + } + this._map = mapInstance; + } + + // 开始创建 + start() { + this._map.on('mousemove', this._onHoverMap); + this._map.on('click', this._onClickMap); + } + + // 结束创建 + end() { + this._map.off('mousemove', this._onHoverMap); + this._map.off('click', this._onClickMap); + this._map.getCanvas().style.cursor = ''; + } + + // 提交结果 + commit() { + if (!this._coordinates.length) return; + this._trigger('completed', this._coordinates.map(coordinate => values(coordinate))); + this.clear(); + } + + // 追加新的坐标点 + _appendToCoordinates(coordinate) { + const newCoordinates = [...this._coordinates]; + newCoordinates.push(coordinate); + this._coordinates = newCoordinates; + } + + // 更新坐标点 + _updateCoordinates(index, coordinate) { + const newCoordinates = [...this._coordinates]; + newCoordinates[index] = coordinate; + this._coordinates = newCoordinates; + } + + _initCoordinateEditor() { + const node = document.createElement('div'); + node.className = 'form'; + node.innerHTML = ` +
+
+
+ + +
+ `; + this._coordinatePopupEditor.setDOMContent(node); + this._coordinatePopupEditor.on('close', () => { + this._activeMarkerIndex = -1; + }); + + node.querySelector('.confirm').addEventListener('click', this._onConfirmCoordinate); + node.querySelector('.close-shape').addEventListener('click', this._onCommitShape); + } + + _updateCoordinateEditor(toggleButton = false) { + if (this._activeMarkerIndex < 0) return; + const [lng, lat] = this._coordinates[this._activeMarkerIndex]; + const node = this._coordinatePopupEditor.getElement(); + if (!node) return; + node.querySelector('.lng').value = lng.toFixed(7); + node.querySelector('.lat').value = lat.toFixed(7); + if (toggleButton) { + const btn = node.querySelector('.close-shape'); + btn.style.display = this._activeMarkerIndex === 0 ? 'inline-block' : 'none'; + } + } + + _onConfirmCoordinate = () => { + const node = this._coordinatePopupEditor.getElement(); + if (!node || this._activeMarkerIndex < 0) return; + const newLng = node.querySelector('.lng').value - 0; + const newLat = node.querySelector('.lat').value - 0; + const marker = this._markers[this._activeMarkerIndex]; + marker.setLngLat([newLng, newLat]); + this._updateCoordinates(this._activeMarkerIndex, [newLng, newLat]); + this._hidePopup(); + }; + + _onCommitShape = () => { + if (this._coordinates.length < 3) return; + if (this._checkKinked()) { + this._trigger('shape_kinked', { action: 'close' }); + return; + } + this._hidePopup(); + this.commit(); + }; + + _hidePopup() { + this._markers.forEach(marker => marker.setPopup(null)); + this._activeMarkerIndex = -1; + } + + // 检测形状是否扭结(判断新坐标加入后,或者对原有坐标进行判断) + _checkKinked(newCoordinate) { + if (this._coordinates.length < 3) return false; + if (newCoordinate) { + const lineFeature = turf.lineString([...this._coordinates, newCoordinate]); + const kinks = turf.kinks(lineFeature); + return !!kinks.features.length; + } + const kinks = turf.kinks(this._shapeFillFeature); + return !!kinks.features.length; + } + + // 生成操作点 + _genMarkerNode() { + const node = document.createElement('div'); + node.style.cursor = 'pointer'; + node.style.width = '12px'; + node.style.height = '12px'; + node.style.borderRadius = '50%'; + node.style.background = this._style.markerFillColor; + node.style.boxSizing = 'border-box'; + node.style.border = `2px solid ${this._style.markerEdgeColor}`; + node.addEventListener('click', this._onClickMarker, false); + node.addEventListener('mousemove', this._onMouseMoveMarker); + node.addEventListener('mouseleave', this._onMouseLeaveMarker); + return node; + } + + _onClickMarker = e => { + e.stopPropagation(); + const { markerIndex } = e.target.dataset; + this._markers.forEach(marker => marker.setPopup(null)); + if (markerIndex >= 0) { + this._activeMarkerIndex = markerIndex - 0; + const marker = this._markers[markerIndex]; + marker.setPopup(this._coordinatePopupEditor); + if (!marker.getPopup().isOpen()) { + marker.togglePopup(); + } + this._updateCoordinateEditor(true); + } + }; + + _onMouseMoveMarker = e => { + const { markerIndex } = e.target.dataset; + if (markerIndex === undefined) return; + const marker = this._markers[markerIndex]; + if (this._activeMarkerIndex >= 0) return; + marker.setPopup(!(markerIndex - 0) ? this._completeTooltip : this._markerTooltip); + marker.togglePopup(); + }; + + _onMouseLeaveMarker = e => { + const { markerIndex } = e.target.dataset; + if (markerIndex === undefined) return; + const marker = this._markers[markerIndex]; + const popup = marker.getPopup(); + if (!popup || popup === this._coordinatePopupEditor) return; + if (popup.isOpen()) { + marker.togglePopup(); + } + }; + + _onDragMarker = e => { + const marker = e.target; + const index = this._markers.indexOf(marker); + const { lng: newLng, lat: newLat } = marker.getLngLat(); + this._updateCoordinates(index, [newLng, newLat]); + if (this._checkKinked()) { + this._isKinked = true; + this._trigger('shape_kinked', { action: 'drag' }); + } else { + // 从扭结复原了 + if (this._isKinked) { + this._trigger('kink_recovered'); + } + this._isKinked = false; + } + }; + + _onClickMap = e => { + const { lng, lat } = e.lngLat; + // 判断新坐标是否会造成扭结(即:新的边线是否会跟其他边线交叉) + if (this._checkKinked([lng, lat])) { + this._trigger('shape_kinked', { action: 'click' }); + return; + } + + const element = this._genMarkerNode(); + const marker = new mapbox.Marker({ + element, + draggable: true, + }).setLngLat(e.lngLat).addTo(this._map); + this._markers.push(marker); + element.dataset.markerIndex = `${this._markers.length - 1}`; + this._appendToCoordinates([lng, lat]); + + marker.on('drag', this._onDragMarker); + }; + + _onHoverMap = () => { + this._map.getCanvas().style.cursor = this._cursor; + }; + + _render() { + if (!this._map) return; + const { length: count } = this._coordinates; + if (count < 2) return; + this._renderEdge(); + if (count < 3) return; + this._renderFill(); + } + + _renderEdge() { + const source = this._map.getSource(this._sourceId.EDGE); + if (!source) { + this._map.addSource(this._sourceId.EDGE, { + type: 'geojson', + data: this._shapeEdgeFeature, + }); + + this._map.addLayer({ + id: this._layerId.EDGE, + type: 'line', + source: this._sourceId.EDGE, + paint: { + 'line-color': this._style.edgeColor, + 'line-width': 2, + }, + }); + } else { + source.setData(this._shapeEdgeFeature); + } + } + + _renderFill() { + const source = this._map.getSource(this._sourceId.FILL); + if (!source) { + this._map.addSource(this._sourceId.FILL, { + type: 'geojson', + data: this._shapeFillFeature, + }); + + this._map.addLayer({ + id: this._layerId.FILL, + type: 'fill', + source: this._sourceId.FILL, + paint: { + 'fill-color': this._style.fillColor, + }, + }); + } else { + source.setData(this._shapeFillFeature); + } + } + + _clearMarkers() { + this._markers.forEach(m => m.remove()); + this._markers = []; + } + + clear(end = false) { + if (!this._map) return; + if (this._map.getSource(this._sourceId.FILL)) { + this._map.removeLayer(this._layerId.FILL); + this._map.removeSource(this._sourceId.FILL); + } + if (this._map.getSource(this._sourceId.EDGE)) { + this._map.removeLayer(this._layerId.EDGE); + this._map.removeSource(this._sourceId.EDGE); + } + this._clearMarkers(); + this._coordinates = []; + if (end) this.end(); + } + + destroy() { + this.clear(); + this.end(); + this._map = null; + this.initStyle(); + } +} + +export default ShapeCreator; diff --git a/src/core/ShapeEditor.js b/src/core/ShapeEditor.js new file mode 100644 index 0000000..b834b01 --- /dev/null +++ b/src/core/ShapeEditor.js @@ -0,0 +1,562 @@ +/** + * 多边形编辑 + */ +import { action, computed, makeObservable, observable, reaction, values } from 'mobx'; +import * as turf from '@turf/turf'; +import mapbox from 'mapbox-gl'; +import EventDispatcher from './EventDispatcher'; + +class ShapeEditor extends EventDispatcher { + _topic = null; + + // 地图实例 + _map = null; + + _dataSource = {}; + + // 构成形状的点坐标 + _coordinates = []; + + // 操作控制点 + _markers = []; + + // 形状是否扭结 + _isKinked = false; + + // 当前激活的marker索引 + _activeMarkerIndex = -1; + + // 操控点提示 + _markerTooltip = (new mapbox.Popup({ closeButton: false, offset: 6, className: 'shape-editor-popup' })).setHTML('按住可拖拽控制点
点击可微调、删除控制点'); + + // 扩展点提示 + _extPointTooltip = (new mapbox.Popup({ closeButton: false, offset: 4, className: 'shape-editor-popup' })).setText('点击可添加控制点'); + + // 坐标编辑器 + _coordinatePopupEditor = new mapbox.Popup({ offset: 6, className: 'shape-editor-popup' }); + + // 在地图上点击以外区域时,是否自动提交本次编辑 + _autoCommit = false; + + // 视觉选项 + _defaultStyle = { + edgeColor: 'rgba(0, 0, 255, 0.8)', + markerFillColor: 'rgba(0, 0, 255, 1)', + markerEdgeColor: 'rgba(255, 255, 255, 1)', + }; + + _style = {}; + + get _sourceId() { + return { + FILL: `${this._topic}-shape-editor-fill-source`, + EDGE: `${this._topic}-shape-editor-edge-source`, + VERTEX: `${this._topic}-shape-editor-vertex-source`, + EXT_POINT: `${this._topic}-shape-editor-ext-point-source`, + }; + } + + get _layerId() { + return { + FILL: `${this._topic}-shape-editor-fill-layer`, + EDGE: `${this._topic}-shape-editor-edge-layer`, + VERTEX: `${this._topic}-shape-editor-vertex-layer`, + EXT_POINT: `${this._topic}-shape-editor-ext-point-layer`, + }; + } + + // 形状填充 + get _shapeFillFeature() { + return turf.lineToPolygon(turf.lineString(this._coordinates)); + } + + // 形状集合包围盒 + get _shapeBoundingBox() { + return turf.bbox(this._shapeFillFeature); + } + + // 形状边界线 + get _shapeEdgeFeature() { + return turf.polygonToLine(this._shapeFillFeature); + } + + // 形状顶点 + get _shapeVertexFeature() { + return turf.multiPoint(this._coordinates); + } + + // 形状扩展点 + get _shapeExtPointFeatureCollection() { + const [first] = this._coordinates; + if (!first) return turf.featureCollection([]); + + const points = this._coordinates.concat([first]).map(coordinate => turf.point(coordinate)); + const midPoints = []; + for (let i = 1; i < points.length; i += 1) { + const midPoint = turf.midpoint(points[i - 1], points[i]); + midPoint.id = i; + midPoints.push(midPoint); + } + return turf.featureCollection(midPoints); + } + + constructor(topic, autoCommit = false, mapInstance = null) { + super(['shape_kinked', 'kink_recovered', 'completed']); + this._topic = topic; + this._autoCommit = autoCommit; + if (mapInstance) this.setMap(mapInstance); + + this.initStyle(); + this._initCoordinateEditor(); + + makeObservable(this, { + _topic: observable, + _coordinates: observable, + _sourceId: computed, + _layerId: computed, + _shapeFillFeature: computed, + _shapeEdgeFeature: computed, + _shapeVertexFeature: computed, + _shapeExtPointFeatureCollection: computed, + loadDataSource: action, + _initCoordinates: action, + _updateCoordinates: action, + _insertIntoCoordinates: action, + _deleteCoordinates: action, + clear: action, + }); + + reaction(() => this._coordinates, () => { + this._render(); + this._updateCoordinateEditor(); + }); + } + + // 初始化视觉演示(需在loadTracks之前配置) + initStyle(style = {}) { + this._style = { + ...this._defaultStyle, + ...style, + }; + } + + setMap(mapInstance) { + if (this._map === mapInstance) return; + if (!(mapInstance instanceof mapbox.Map)) { + throw new Error('必须传入一个mapbox地图实例'); + } + this._map = mapInstance; + } + + loadDataSource({ points, ...others } = {}) { + if (!this._map) { + throw new Error('请先设置地图实例'); + } + let newPoints = points || []; + newPoints = Array.isArray(newPoints[0]) ? newPoints : newPoints.map(({ lng, lat }) => [lng, lat]); + this._dataSource = { points: newPoints, ...others }; + this._initCoordinates(); + } + + // 提交编辑 + commit() { + if (!this._coordinates.length) return; + const result = { + ...this._dataSource, + points: this._coordinates.map(coordinate => values(coordinate)), + }; + this._trigger('completed', result); + this.clear(); + } + + // 初始化操控点坐标集 + _initCoordinates() { + const { points } = this._dataSource; + this._coordinates = points; + this._showMarkers(); + } + + // 更新操控点坐标 + _updateCoordinates(index, coordinate) { + const newCoordinates = [...this._coordinates]; + newCoordinates[index] = coordinate; + this._coordinates = newCoordinates; + } + + // 插入新的操控点坐标 + _insertIntoCoordinates(index, coordinate) { + const newCoordinates = [...this._coordinates]; + newCoordinates.splice(index, 0, coordinate); + this._coordinates = newCoordinates; + this._showMarkers(); + } + + // 删除操控点坐标 + _deleteCoordinates(index) { + const newCoordinates = [...this._coordinates]; + newCoordinates.splice(index - 0, 1); + this._coordinates = newCoordinates; + this._hidePopup(); + this._showMarkers(); + } + + _initCoordinateEditor() { + const node = document.createElement('div'); + node.className = 'form'; + node.innerHTML = ` +
+
+
+ + +
+ `; + this._coordinatePopupEditor.setDOMContent(node); + this._coordinatePopupEditor.on('close', () => { + this._activeMarkerIndex = -1; + }); + + node.querySelector('.confirm').addEventListener('click', this._onConfirmCoordinate); + node.querySelector('.delete').addEventListener('click', this._onDeleteMarker); + } + + _updateCoordinateEditor(toggleButton = false) { + if (this._activeMarkerIndex < 0 || !this._coordinates.length) return; + const [lng, lat] = this._coordinates[this._activeMarkerIndex]; + const node = this._coordinatePopupEditor.getElement(); + if (!node) return; + node.querySelector('.lng').value = lng.toFixed(7); + node.querySelector('.lat').value = lat.toFixed(7); + if (toggleButton) { + const btn = node.querySelector('.delete'); + btn.style.display = this._coordinates.length > 3 ? 'inline-block' : 'none'; + } + } + + _onConfirmCoordinate = () => { + const node = this._coordinatePopupEditor.getElement(); + if (!node || this._activeMarkerIndex < 0) return; + const newLng = node.querySelector('.lng').value - 0; + const newLat = node.querySelector('.lat').value - 0; + const marker = this._markers[this._activeMarkerIndex]; + marker.setLngLat([newLng, newLat]); + this._updateCoordinates(this._activeMarkerIndex, [newLng, newLat]); + this._hidePopup(); + }; + + _onDeleteMarker = () => { + if (this._coordinates.length <= 3) return; + if (this._activeMarkerIndex < 0) return; + this._deleteCoordinates(this._activeMarkerIndex); + }; + + _hidePopup() { + this._markers.forEach(marker => marker.setPopup(null)); + this._activeMarkerIndex = -1; + } + + _render() { + if (!this._map) return; + if (this._coordinates.length < 3) return; + + this._renderFill(); + this._renderEdge(); + this._renderVertex(); + this._renderExtPoint(); + + this._map.off('click', this._onClickMap); + this._map.on('click', this._onClickMap); + } + + // 渲染填充层,仅用于鼠标点击 + _renderFill() { + const source = this._map.getSource(this._sourceId.FILL); + if (!source) { + this._map.addSource(this._sourceId.FILL, { + type: 'geojson', + data: this._shapeFillFeature, + }); + + this._map.addLayer({ + id: this._layerId.FILL, + type: 'fill', + source: this._sourceId.FILL, + paint: { + 'fill-color': 'rgba(0, 0, 0, 0.1)', + }, + }); + } else { + source.setData(this._shapeFillFeature); + } + } + + _renderEdge() { + const source = this._map.getSource(this._sourceId.EDGE); + if (!source) { + this._map.addSource(this._sourceId.EDGE, { + type: 'geojson', + data: this._shapeEdgeFeature, + }); + + this._map.addLayer({ + id: this._layerId.EDGE, + type: 'line', + source: this._sourceId.EDGE, + paint: { + 'line-color': this._style.edgeColor, + 'line-width': 2, + 'line-dasharray': [1, 2], + }, + layout: { + 'line-cap': 'round', + }, + }); + } else { + source.setData(this._shapeEdgeFeature); + } + } + + // 渲染顶点,仅用于鼠标点击 + _renderVertex() { + const source = this._map.getSource(this._sourceId.VERTEX); + if (!source) { + this._map.addSource(this._sourceId.VERTEX, { + type: 'geojson', + data: this._shapeVertexFeature, + }); + + this._map.addLayer({ + id: this._layerId.VERTEX, + type: 'circle', + source: this._sourceId.VERTEX, + paint: { + 'circle-color': this._style.edgeColor, + 'circle-radius': 4, + }, + }); + } else { + source.setData(this._shapeVertexFeature); + } + } + + // 渲染扩展点 + _renderExtPoint() { + const source = this._map.getSource(this._sourceId.EXT_POINT); + if (!source) { + this._map.addSource(this._sourceId.EXT_POINT, { + type: 'geojson', + data: this._shapeExtPointFeatureCollection, + }); + + this._map.addLayer({ + id: this._layerId.EXT_POINT, + type: 'circle', + source: this._sourceId.EXT_POINT, + paint: { + 'circle-color': this._style.edgeColor, + 'circle-radius': 4, + }, + }); + + this._map.on('click', this._layerId.EXT_POINT, this._onClickExtPoint); + this._map.on('mousemove', this._layerId.EXT_POINT, this._onMouserMoveExtPoint); + this._map.on('mouseleave', this._layerId.EXT_POINT, this._onMouserLeaveExtPoint); + } else { + source.setData(this._shapeExtPointFeatureCollection); + } + } + + _onClickMap = e => { + if (!this._autoCommit) return; + const features = this._map.queryRenderedFeatures(e.point, { + layers: [ + this._layerId.FILL, + this._layerId.EDGE, + this._layerId.VERTEX, + this._layerId.EXT_POINT, + ], + }); + // length>=0表示点击了编辑区域,否则点击编辑区域以外(即:结束编辑) + if (features.length) return; + + if (this._checkKinked()) { + this._trigger('shape_kinked', { action: 'close' }); + return; + } + + this.commit(); + }; + + // 点击扩展点(即:插入新的操控点) + _onClickExtPoint = e => { + const [feature] = e.features; + this._insertIntoCoordinates(feature.id, turf.getCoord(feature)); + }; + + _onMouserMoveExtPoint = e => { + this._map.getCanvas().style.cursor = 'pointer'; + const [feature] = e.features; + this._extPointTooltip.setLngLat(turf.getCoord(feature)).addTo(this._map); + }; + + _onMouserLeaveExtPoint = () => { + this._map.getCanvas().style.cursor = ''; + this._extPointTooltip.remove(); + }; + + _onMouseMoveMarker = e => { + const { markerIndex } = e.target.dataset; + if (markerIndex === undefined) return; + const marker = this._markers[markerIndex]; + if (this._activeMarkerIndex >= 0) return; + marker.setPopup(this._markerTooltip); + if (!marker.getPopup().isOpen()) { + marker.togglePopup(); + } + }; + + _onMouseLeaveMarker = e => { + const { markerIndex } = e.target.dataset; + if (markerIndex === undefined) return; + const marker = this._markers[markerIndex]; + const popup = marker.getPopup(); + if (!popup || popup === this._coordinatePopupEditor) return; + if (popup.isOpen()) { + marker.togglePopup(); + } + }; + + _onClickMarker = e => { + e.stopPropagation(); + const { markerIndex } = e.target.dataset; + this._markers.forEach(marker => marker.setPopup(null)); + if (markerIndex >= 0) { + this._activeMarkerIndex = markerIndex - 0; + const marker = this._markers[markerIndex]; + marker.setPopup(this._coordinatePopupEditor); + if (!marker.getPopup().isOpen()) { + marker.togglePopup(); + } + this._updateCoordinateEditor(true); + } + }; + + _onDragMarker = e => { + const marker = e.target; + const index = this._markers.indexOf(marker); + const { lng: newLng, lat: newLat } = marker.getLngLat(); + this._updateCoordinates(index, [newLng, newLat]); + if (this._checkKinked()) { + this._isKinked = true; + this._trigger('shape_kinked', { action: 'drag' }); + } else { + // 从扭结复原了 + if (this._isKinked) { + this._trigger('kink_recovered'); + } + this._isKinked = false; + } + }; + + // 检测形状是否扭结(判断新坐标加入后,或者对原有坐标进行判断) + _checkKinked(newCoordinate) { + if (this._coordinates.length < 3) return false; + if (newCoordinate) { + const lineFeature = turf.lineString([...this._coordinates, newCoordinate]); + const kinks = turf.kinks(lineFeature); + return !!kinks.features.length; + } + const kinks = turf.kinks(this._shapeFillFeature); + return !!kinks.features.length; + } + + // 生成操作点 + _genMarkerNode() { + const node = document.createElement('div'); + node.style.cursor = 'pointer'; + node.style.width = '12px'; + node.style.height = '12px'; + node.style.borderRadius = '50%'; + node.style.background = this._style.markerFillColor; + node.style.boxSizing = 'border-box'; + node.style.border = `2px solid ${this._style.markerEdgeColor}`; + node.addEventListener('mousemove', this._onMouseMoveMarker); + node.addEventListener('mouseleave', this._onMouseLeaveMarker); + node.addEventListener('click', this._onClickMarker, false); + return node; + } + + // 显示操控点 + _showMarkers() { + this._clearMarkers(); + this._coordinates.forEach(coordinate => { + const element = this._genMarkerNode(); + const marker = new mapbox.Marker({ + element, + draggable: true, + }).setLngLat(coordinate).addTo(this._map); + this._markers.push(marker); + element.dataset.markerIndex = `${this._markers.length - 1}`; + + marker.on('drag', this._onDragMarker); + }); + } + + // 缩放到包围盒 + fitView({ top = 0, bottom = 0, left = 0, right = 0 } = {}, cut = 120) { + if (!this._coordinates.length) return; + this._map.setPadding({ top: 0, bottom: 0, left: 0, right: 0 }); + this._map.fitBounds(this._shapeBoundingBox, { + duration: 2000, + padding: { + top: top + cut, + bottom: bottom + cut, + left: left + cut, + right: right + cut, + }, + }); + } + + _clearMarkers() { + this._markers.forEach(m => m.remove()); + this._markers = []; + } + + clear() { + if (!this._map) return; + this._map.off('click', this._onClickMap); + if (this._map.getSource(this._sourceId.FILL)) { + this._map.removeLayer(this._layerId.FILL); + this._map.removeSource(this._sourceId.FILL); + } + if (this._map.getSource(this._sourceId.EDGE)) { + this._map.removeLayer(this._layerId.EDGE); + this._map.removeSource(this._sourceId.EDGE); + } + if (this._map.getSource(this._sourceId.VERTEX)) { + this._map.removeLayer(this._layerId.VERTEX); + this._map.removeSource(this._sourceId.VERTEX); + } + if (this._map.getSource(this._sourceId.EXT_POINT)) { + this._map.off('click', this._layerId.EXT_POINT, this._onClickExtPoint); + this._map.off('mousemove', this._layerId.EXT_POINT, this._onMouserMoveExtPoint); + this._map.off('mouseleave', this._layerId.EXT_POINT, this._onMouserLeaveExtPoint); + this._map.removeLayer(this._layerId.EXT_POINT); + this._map.removeSource(this._sourceId.EXT_POINT); + } + this._clearMarkers(); + this._coordinates = []; + this._activeMarkerIndex = -1; + this._isKinked = false; + } + + // 销毁 + destroy() { + this.clear(); + this._map = null; + this._dataSource = {}; + this.initStyle(); + } +} + +export default ShapeEditor; diff --git a/src/core/ShapeRenderer.js b/src/core/ShapeRenderer.js new file mode 100644 index 0000000..cd1f78d --- /dev/null +++ b/src/core/ShapeRenderer.js @@ -0,0 +1,554 @@ +/** + * 任意多边形在地图上显示 + */ +import { action, computed, makeObservable, observable, reaction } from 'mobx'; +import mapbox from 'mapbox-gl'; +import * as turf from '@turf/turf'; +import EventDispatcher from './EventDispatcher'; + +class ShapeRenderer extends EventDispatcher { + _topic = null; + + // 地图实例 + _map = null; + + _dataSource = []; + + // 是否完成初始渲染 + isRendered = false; + + // 鼠标经过的形状id + _hoveredShapeId = null; + + // 显示配置项 + _defaultOptions = { + // 显示填充 + showFill: true, + // 显示描边 + showStroke: true, + // 显示标签 + showLabel: true, + // 显示标签(仅鼠标经过时) + showLabelOnOver: false, + }; + + _options = {}; + + // 视觉选项 + _defaultStyle = { + fillColor: 'rgba(255, 255, 0, 0.1)', + fillHoverColor: 'rgba(255, 255, 0, 0.3)', + strokeColor: 'rgba(255, 255, 0, 0.5)', + strokeHoverColor: 'rgba(255, 255, 0, 1)', + labelColor: 'rgba(0, 0, 0, 1)', + labelStrokeColor: 'rgba(255, 255, 255, 1)', + labelMinZoom: 3, + }; + + _style = {}; + + get _sourceId() { + return { + FILL: `${this._topic}-shape-fill-source`, + STROKE: `${this._topic}-shape-stroke-source`, + LABEL: `${this._topic}-shape-label-source`, + }; + } + + get _layerId() { + return { + FILL: `${this._topic}-shape-fill-layer`, + STROKE: `${this._topic}-shape-stroke-layer`, + LABEL: `${this._topic}-shape-label-layer`, + }; + } + + // 形状填充色表达式 + get _shapeFillColorExpression() { + return [ + 'case', + [ + 'any', + ['boolean', ['feature-state', 'hover'], false], + ['boolean', ['feature-state', 'highlight'], false], + ], + this._options.showFill ? this._style.fillHoverColor : 'rgba(0, 0, 0, 0)', + this._options.showFill ? this._style.fillColor : 'rgba(0, 0, 0, 0)', + ]; + } + + // 形状描边色表达式 + get _shapeStrokeColorExpression() { + return [ + 'case', + [ + 'any', + ['boolean', ['feature-state', 'hover'], false], + ['boolean', ['feature-state', 'highlight'], false], + ], + this._options.showStroke ? this._style.strokeHoverColor : 'rgba(0, 0, 0, 0)', + this._options.showStroke ? this._style.strokeColor : 'rgba(0, 0, 0, 0)', + ]; + } + + // 形状标签透明度表达式 + get _shapeLabelOpacityExpression() { + if (this._options.showLabelOnOver) { + return [ + 'case', + [ + 'any', + ['boolean', ['feature-state', 'hover'], false], + ['boolean', ['feature-state', 'highlight'], false], + ], + 1, + 0, + ]; + } + return [ + 'interpolate', ['linear'], + ['zoom'], + this._style.labelMinZoom - 0.01, 0, + this._style.labelMinZoom, 1, + ]; + } + + // 形状填充 + get _shapeFillFeatures() { + return this._dataSource.map(({ id, points, ...others }) => { + const newPoints = Array.isArray(points[0]) ? points : points.map(({ lng, lat }) => [lng, lat]); + const lineFeature = turf.lineString(newPoints, { id, ...others }); + const polygonFeature = turf.lineToPolygon(lineFeature); + polygonFeature.id = id; + return polygonFeature; + }); + } + + // 形状填充集合 + get _shapeFillFeatureCollection() { + return turf.featureCollection(this._shapeFillFeatures); + } + + // 形状描边 + get _shapeStrokeFeatures() { + return this._shapeFillFeatures.map(polygonFeature => { + const lineFeature = turf.polygonToLine(polygonFeature); + const { id } = lineFeature.properties; + lineFeature.id = id; + return lineFeature; + }); + } + + // 形状描边集合 + get _shapeStrokeFeatureCollection() { + return turf.featureCollection(this._shapeStrokeFeatures); + } + + // 形状中心点 + get _shapeCenterFeatures() { + return this._shapeFillFeatures.map(polygonFeature => { + const feature = turf.centerOfMass(polygonFeature); + feature.id = polygonFeature.id; + feature.properties = polygonFeature.properties; + return feature; + }); + } + + // 形状中心点集合 + get _shapeCenterFeatureCollection() { + return turf.featureCollection(this._shapeCenterFeatures); + } + + // 形状集合包围盒 + get _shapeBoundingBox() { + return turf.bbox(this._shapeFillFeatureCollection); + } + + constructor(topic, mapInstance = null) { + super(['rendered', 'mouse_move', 'mouse_leave']); + this._topic = topic; + if (mapInstance) this.setMap(mapInstance); + + this.updateOptions(); + this.updateStyle(); + + makeObservable(this, { + _topic: observable, + _dataSource: observable, + _options: observable, + _style: observable, + _sourceId: computed, + _layerId: computed, + _shapeFillColorExpression: computed, + _shapeStrokeColorExpression: computed, + _shapeLabelOpacityExpression: computed, + _shapeFillFeatures: computed, + _shapeFillFeatureCollection: computed, + _shapeStrokeFeatures: computed, + _shapeStrokeFeatureCollection: computed, + _shapeCenterFeatures: computed, + _shapeCenterFeatureCollection: computed, + _shapeBoundingBox: computed, + loadDataSource: action, + updateOptions: action, + updateStyle: action, + destroy: action, + }); + + reaction(() => this._dataSource, () => { + this._render(); + }); + } + + // 更新配置项 + updateOptions(options = {}) { + if (Object.keys(options).length) { + this._options = { + ...this._options, + ...options, + }; + this._refreshVisibility(); + } else { + this._options = { + ...this._defaultOptions, + }; + } + } + + // 更新视觉样式 + updateStyle(style = {}) { + if (Object.keys(style).length) { + this._style = { + ...this._style, + ...style, + }; + this._repaintStyle(); + } else { + this._style = { + ...this._defaultStyle, + }; + } + } + + setMap(mapInstance) { + if (this._map === mapInstance) return; + if (!(mapInstance instanceof mapbox.Map)) { + throw new Error('必须传入一个mapbox地图实例'); + } + this._map = mapInstance; + } + + // 载入数据(list->item必须包含id、name、points数组,points->item如果是对象则必须包含lng、lat属性,如果是数组则必须是[lng, lat]) + loadDataSource(list) { + if (!this._map) { + throw new Error('请先设置地图实例'); + } + this._dataSource = (list || []).map(item => { + const { id, points } = item; + return (id >= 0 && Array.isArray(points) && points.length >= 3) ? item : null; + }).filter(Boolean); + } + + // 是否包含某个多边形 + hasShape(shapeId) { + return this._dataSource.findIndex(({ id }) => shapeId === id) >= 0; + } + + // 获取某个形状数据 + getShape(shapeId) { + return this._dataSource.find(({ id }) => shapeId === id); + } + + // 获取所有形状的中心点 + getAllCenters() { + return this._shapeCenterFeatures.map(feature => turf.getCoord(feature)); + } + + _render() { + if (!this._map) return; + if (this._options.showFill) this._renderShapeFill(); + if (this._options.showStroke) this._renderShapeStroke(); + if (this._options.showLabel) this._renderShapeLabel(); + this.isRendered = true; + this._trigger('rendered'); + } + + // 渲染形状填充 + _renderShapeFill() { + const source = this._map.getSource(this._sourceId.FILL); + if (!source) { + this._map.addSource(this._sourceId.FILL, { + type: 'geojson', + data: this._shapeFillFeatureCollection, + }); + + this._map.addLayer({ + id: this._layerId.FILL, + type: 'fill', + source: this._sourceId.FILL, + paint: { + 'fill-color': this._shapeFillColorExpression, + 'fill-opacity': [ + 'case', + ['boolean', ['feature-state', 'visible'], true], + 1, + 0, + ], + }, + }); + + this._map.on('click', this._layerId.FILL, this._onClick); + this._map.on('mousemove', this._layerId.FILL, this._onMouseMove); + this._map.on('mouseleave', this._layerId.FILL, this._onMouseLeave); + } else { + source.setData(this._shapeFillFeatureCollection); + } + } + + // 渲染形状描边 + _renderShapeStroke() { + const source = this._map.getSource(this._sourceId.STROKE); + if (!source) { + this._map.addSource(this._sourceId.STROKE, { + type: 'geojson', + data: this._shapeStrokeFeatureCollection, + }); + + this._map.addLayer({ + id: this._layerId.STROKE, + type: 'line', + source: this._sourceId.STROKE, + paint: { + 'line-color': this._shapeStrokeColorExpression, + 'line-width': 2, + 'line-opacity': [ + 'case', + ['boolean', ['feature-state', 'visible'], true], + 1, + 0, + ], + }, + layout: { + 'line-cap': 'round', + }, + }); + } else { + source.setData(this._shapeStrokeFeatureCollection); + } + } + + _renderShapeLabel() { + const source = this._map.getSource(this._sourceId.LABEL); + if (!source) { + this._map.addSource(this._sourceId.LABEL, { + type: 'geojson', + data: this._shapeCenterFeatureCollection, + }); + + this._map.addLayer({ + id: this._layerId.LABEL, + type: 'symbol', + source: this._sourceId.LABEL, + layout: { + 'text-field': '{name}', + 'text-size': 12, + 'text-allow-overlap': true, + }, + paint: { + 'text-color': this._style.labelColor, + 'text-halo-color': this._style.labelStrokeColor, + 'text-halo-width': 1, + 'text-opacity': this._shapeLabelOpacityExpression, + }, + }); + } else { + source.setData(this._shapeCenterFeatureCollection); + } + } + + _onClick = e => { + if (!e.features.length) return; + const { lngLat, point } = e; + const [{ properties: detail }] = e.features; + this._trigger('click', { lngLat, point, detail }); + }; + + _onMouseMove = e => { + this._map.getCanvas().style.cursor = 'pointer'; + + if (e.features.length > 0) { + if (this._hoveredShapeId !== null) { + const id = this._hoveredShapeId; + if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id }, { hover: false }); + if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id }, { hover: false }); + if (this._map.getSource(this._sourceId.LABEL)) this._map.setFeatureState({ source: this._sourceId.LABEL, id }, { hover: false }); + } + const [{ id }] = e.features; + if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id }, { hover: true }); + if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id }, { hover: true }); + if (this._map.getSource(this._sourceId.LABEL)) this._map.setFeatureState({ source: this._sourceId.LABEL, id }, { hover: true }); + this._hoveredShapeId = id; + } + + const { lngLat, point } = e; + const [{ properties: detail }] = e.features; + this._trigger('mouse_move', { lngLat, point, detail }); + }; + + _onMouseLeave = e => { + this._map.getCanvas().style.cursor = ''; + + if (this._hoveredShapeId !== null) { + if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id: this._hoveredShapeId }, { hover: false }); + if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id: this._hoveredShapeId }, { hover: false }); + if (this._map.getSource(this._sourceId.LABEL)) this._map.setFeatureState({ source: this._sourceId.LABEL, id: this._hoveredShapeId }, { hover: false }); + } + + const { lngLat, point } = e; + let detail = {}; + if (this._hoveredShapeId !== null) { + const { id, points, ...others } = this._dataSource.find(item => item.id === this._hoveredShapeId) || {}; + detail = { id, ...others }; + } + this._trigger('mouse_leave', { lngLat, point, detail }); + + this._hoveredShapeId = null; + }; + + // 隐藏某个形状 + hideShape(shapeId) { + if (!this._map || !this.hasShape(shapeId)) return; + if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id: shapeId }, { visible: false }); + if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id: shapeId }, { visible: false }); + } + + // 显示某个形状 + showShape(shapeId) { + if (!this._map || !this.hasShape(shapeId)) return; + if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id: shapeId }, { visible: true }); + if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id: shapeId }, { visible: true }); + } + + // 高亮某个形状(或取消高亮) + highlightShape(shapeId, stateValue = true) { + if (!this._map || !this.hasShape(shapeId)) return; + if (this._map.getSource(this._sourceId.FILL)) this._map.setFeatureState({ source: this._sourceId.FILL, id: shapeId }, { highlight: stateValue }); + if (this._map.getSource(this._sourceId.STROKE)) this._map.setFeatureState({ source: this._sourceId.STROKE, id: shapeId }, { highlight: stateValue }); + } + + // 高亮一组形状(或取消高亮) + highlightShapes(keyName, keyValue, stateValue = true) { + if (!this._map) return; + turf.featureEach(this._shapeFillFeatureCollection, currentFeature => { + const { id } = currentFeature; + if (currentFeature.properties[keyName] === keyValue) this.highlightShape(id, stateValue); + }); + } + + // 刷新可见性 + _refreshVisibility() { + if (!this._map) return; + if (this._map.getLayer(this._layerId.FILL)) { + this._renderShapeFill(); + this._map.setLayoutProperty(this._layerId.FILL, 'visibility', this._options.showFill ? 'visible' : 'none'); + } + if (this._map.getLayer(this._layerId.STROKE)) { + this._renderShapeStroke(); + this._map.setLayoutProperty(this._layerId.STROKE, 'visibility', this._options.showStroke ? 'visible' : 'none'); + } + if (this._map.getLayer(this._layerId.LABEL)) { + this._renderShapeLabel(); + this._map.setLayoutProperty(this._layerId.LABEL, 'visibility', this._options.showLabel ? 'visible' : 'none'); + } + } + + // 重绘样式 + _repaintStyle() { + if (!this._map) return; + if (this._map.getLayer(this._layerId.FILL)) { + this._map.setPaintProperty(this._layerId.FILL, 'fill-color', this._shapeFillColorExpression); + } + if (this._map.getLayer(this._layerId.STROKE)) { + this._map.setPaintProperty(this._layerId.STROKE, 'line-color', this._shapeStrokeColorExpression); + } + if (this._map.getLayer(this._layerId.LABEL)) { + this._map.setPaintProperty(this._layerId.LABEL, 'text-color', this._style.labelColor); + this._map.setPaintProperty(this._layerId.LABEL, 'text-halo-color', this._style.labelStrokeColor); + this._map.setPaintProperty(this._layerId.LABEL, 'text-opacity', this._shapeLabelOpacityExpression); + } + } + + // 缩放到某个形状的包围盒 + fitShape(shapeId, { top = 0, bottom = 0, left = 0, right = 0 } = {}, cut = 120) { + if (!this._map || !this.hasShape(shapeId)) return; + const { points } = this._dataSource.find(({ id }) => id === shapeId) || {}; + if (!points) return; + const newPoints = Array.isArray(points[0]) ? points : points.map(({ lng, lat }) => [lng, lat]); + const pointFeature = turf.multiPoint(newPoints); + this._map.setPadding({ top: 0, bottom: 0, left: 0, right: 0 }); + this._map.fitBounds(turf.bbox(pointFeature), { + duration: 2000, + padding: { + top: top + cut, + bottom: bottom + cut, + left: left + cut, + right: right + cut, + }, + }); + } + + // 缩放到所有形状的包围盒 + fitView({ top = 0, bottom = 0, left = 0, right = 0 } = {}, cut = 120) { + if (!this._dataSource.length) return; + this._map.setPadding({ top: 0, bottom: 0, left: 0, right: 0 }); + this._map.fitBounds(this._shapeBoundingBox, { + duration: 2000, + padding: { + top: top + cut, + bottom: bottom + cut, + left: left + cut, + right: right + cut, + }, + }); + } + + // 对其到所有形状的中心 + alignCenter(centerOffset = 0) { + if (!this._dataSource.length) return; + const feature = turf.bboxPolygon(this._shapeBoundingBox); + const center = turf.center(feature); + this._map.panTo(turf.getCoord(center), { + offset: [centerOffset, 0], + }); + } + + clear() { + if (!this._map) return; + this.isRendered = false; + if (this._map.getSource(this._sourceId.FILL)) { + this._map.off('click', this._layerId.FILL, this._onClick); + this._map.off('mousemove', this._layerId.FILL, this._onMouseMove); + this._map.off('mouseleave', this._layerId.FILL, this._onMouseLeave); + this._map.removeLayer(this._layerId.FILL); + this._map.removeSource(this._sourceId.FILL); + } + if (this._map.getSource(this._sourceId.STROKE)) { + this._map.removeLayer(this._layerId.STROKE); + this._map.removeSource(this._sourceId.STROKE); + } + if (this._map.getSource(this._sourceId.LABEL)) { + this._map.removeLayer(this._layerId.LABEL); + this._map.removeSource(this._sourceId.LABEL); + } + } + + destroy() { + this.clear(); + this._map = null; + this._dataSource = []; + this.updateStyle(); + this.updateOptions(); + } +} + +export default ShapeRenderer; diff --git a/src/core/mapHelper.js b/src/core/mapHelper.js new file mode 100644 index 0000000..4666df0 --- /dev/null +++ b/src/core/mapHelper.js @@ -0,0 +1,90 @@ +/** + * 地图助手 + */ +import mapbox from 'mapbox-gl'; +import * as turf from '@turf/turf'; + +class MapHelper { + _map = null; + + setMap(mapInstance) { + if (this._map === mapInstance) return; + if (!(mapInstance instanceof mapbox.Map)) { + throw new Error('必须传入一个mapbox地图实例'); + } + this._map = mapInstance; + window.map = mapInstance; + } + + // 改变高清图层状态 + changeHdLayer(toStatus, map = this._map) { + if (!map.getSource('hd-tiles')) { + map.addSource('hd-tiles', { + type: 'raster', + tiles: ['http://jiagu-map.oss-cn-shanghai.aliyuncs.com/tile/{z}/{x}/{y}.webp'], + tileSize: 256, + minzoom: 12, + maxzoom: 24, + }); + + map.addLayer({ + id: 'hd-tiles-layer', + type: 'raster', + source: 'hd-tiles', + layout: { + visibility: 'none', + }, + }, 'keyLayer'); + } + map.setLayoutProperty('hd-tiles-layer', 'visibility', toStatus ? 'visible' : 'none'); + } + + // 获取一些点的包围盒(像素值) + getBoundingBoxProject = (points = []) => { + if (!this._map) return null; + const pointsFeature = turf.multiPoint(points); + const [westLng, southLat, eastLng, northLat] = turf.bbox(pointsFeature); + // project返回的是dom坐标系┏ + const { x: x1, y: y1 } = this._map.project([westLng, southLat]); + const { x: x2, y: y2 } = this._map.project([eastLng, northLat]); + // 将dom坐标转换为webgl坐标┗ + const { height } = this._map.getCanvas().getBoundingClientRect(); + return [x1, height - y1, x2, height - y2]; + }; + + // 获取包围盒区域图像数据 + getImageData(bbox, padding = 0, size = 0) { + const [x1, y1, x2, y2] = bbox; + const glWidth = Math.abs(x2 - x1).toFixed(0) - 0 + padding * 2; + const glHeight = Math.abs(y2 - y1).toFixed(0) - 0 + padding * 2; + const diff = glWidth - glHeight; // 宽高差 + const offset = Math.ceil(Math.abs(diff / 2)); // 起点偏移量 + const originSize = diff >= 0 ? glWidth : glHeight; + const x = diff >= 0 ? (x1.toFixed(0) - padding) : (x1.toFixed(0) - offset - padding); + const y = diff < 0 ? (y1.toFixed(0) - padding) : (y1.toFixed(0) - offset - padding); + + const gl = this._map.getCanvas().getContext('webgl'); + const pixels = new Uint8Array(originSize * originSize * 4); + gl.readPixels(x, y, originSize, originSize, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + + const imageData = new ImageData(new Uint8ClampedArray(pixels), originSize, originSize); + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = originSize; + tempCanvas.height = originSize; + const tempCtx = tempCanvas.getContext('2d'); + tempCtx.putImageData(imageData, 0, 0); + + const canvas = document.createElement('canvas'); + canvas.width = size || originSize; + canvas.height = size || originSize; + const ctx = canvas.getContext('2d'); + ctx.setTransform(1, 0, 0, -1, 0, canvas.height); + ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height); + + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), 'image/png'); + }); + } +} + +export default new MapHelper(); diff --git a/src/layout/MainContainer.vue b/src/layout/MainContainer.vue index 7fd593e..e29dbc1 100644 --- a/src/layout/MainContainer.vue +++ b/src/layout/MainContainer.vue @@ -2,13 +2,14 @@ import { RouterView } from 'vue-router'; import MapLayer from '@/layout/MapLayer.vue'; import SideMenu from '@/layout/components/SideMenu.vue'; + import TopBar from '@/layout/components/TopBar.vue'; diff --git a/src/layout/components/TopBar.vue b/src/layout/components/TopBar.vue new file mode 100644 index 0000000..abbe05b --- /dev/null +++ b/src/layout/components/TopBar.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/src/layout/components/ZoomBar.vue b/src/layout/components/ZoomBar.vue new file mode 100644 index 0000000..806bf42 --- /dev/null +++ b/src/layout/components/ZoomBar.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/src/router/index.js b/src/router/index.js index 5cf5984..df8354f 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,18 +1,32 @@ import { createRouter, createWebHistory } from 'vue-router'; -// import auth from '@/utils/auth'; +import auth from '@/utils/auth'; const ExampleView = () => import('@/views/ExampleView/ExampleView.vue'); const LoginView = () => import('@/views/LoginView/LoginView.vue'); +const NoFlyZoneView = () => import('@/views/NoFlyZoneView/NoFlyZoneView.vue'); +const NoFlyZoneEditorView = () => import('@/views/NoFlyZoneEditorView/NoFlyZoneEditorView.vue'); +const ManufacturerView = () => import('@/views/ManufacturerView/ManufacturerView.vue'); +const RegulatorView = () => import('@/views/RegulatorView/RegulatorView.vue'); const routes = [ { path: '/', - redirect: () => '/login', + redirect: () => '/example', }, { path: '/example', name: 'ExampleView', component: ExampleView }, { path: '/login', name: 'LoginView', component: LoginView }, + + { path: '/no-fly-zone', name: 'NoFlyZoneView', component: NoFlyZoneView }, + + { path: '/no-fly-zone/create', name: 'NoFlyZoneCreateView', component: NoFlyZoneEditorView, meta: { group: 'NoFlyZoneView' } }, + + { path: '/no-fly-zone/:id/edit', name: 'NoFlyZoneEditorView', component: NoFlyZoneEditorView, meta: { group: 'NoFlyZoneView' } }, + + { path: '/manufacturers', name: 'ManufacturerView', component: ManufacturerView }, + + { path: '/regulators', name: 'RegulatorView', component: RegulatorView }, ]; const router = createRouter({ @@ -21,16 +35,16 @@ const router = createRouter({ }); // 前置路由钩子 -// router.beforeEach((to, from, next) => { -// // 必须先登录 -// if (!['LoginView'].includes(to.name) && !auth.checkToken()) { -// return next({ -// name: 'LoginView', -// replace: true, -// }); -// } -// -// return next(); -// }); +router.beforeEach((to, from, next) => { + // 必须先登录 + if (!['LoginView'].includes(to.name) && !auth.checkToken()) { + return next({ + name: 'LoginView', + replace: true, + }); + } + + return next(); +}); export default router; diff --git a/src/stores/index.js b/src/stores/index.js index 0b0a4ed..e10347e 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -7,3 +7,7 @@ const store = createPinia(); export default store; export * from './modules/exampleStore'; +export * from './modules/noFlyZoneStore'; +export * from './modules/authStore'; +export * from './modules/manufacturerStore'; +export * from './modules/regulatorStore'; diff --git a/src/stores/modules/authStore.js b/src/stores/modules/authStore.js new file mode 100644 index 0000000..4f31301 --- /dev/null +++ b/src/stores/modules/authStore.js @@ -0,0 +1,58 @@ +/** + * authStore + */ +import { ref } from 'vue'; +import { defineStore } from 'pinia'; +import http from '@/utils/http'; +import * as helpers from '@/utils/helpers'; +import * as urls from '@/config/urls'; +import auth from '@/utils/auth'; +import UserInfo from '@/utils/UserInfo'; + +export const useAuthStore = defineStore('auth', () => { + // state + const userInfo = ref(UserInfo.get()); + + // const isPlatform = computed(() => { + // const { roles } = userInfo; + // return (roles || []).includes('platform'); + // }); + + // const isFarmer = computed(() => { + // const { roles } = userInfo; + // return (roles || []).includes('farmer'); + // }); + + // action + function loginWithPassword(formData = {}) { + const requestDate = helpers.pick(formData, [ + 'phone', + 'password', + ]); + return http.getInstance(false).post(urls.LOGIN_WITH_PASSWORD, requestDate).then(({ data }) => { + // 获取信息 + const { data: { accessToken } = {}, extra = {} } = data || {}; + // 更新信息 + const newUserInfo = { + ...userInfo.value, + ...extra, + }; + userInfo.value = { ...newUserInfo }; + // 保存信息 + UserInfo.save(newUserInfo); + if (accessToken) { + auth.saveToken(accessToken); + } + return data; + }); + } + + return { + userInfo, + // isPlatform, + // isFarmer, + loginWithPassword, + }; +}); + +export default {}; diff --git a/src/stores/modules/manufacturerStore.js b/src/stores/modules/manufacturerStore.js new file mode 100644 index 0000000..1684994 --- /dev/null +++ b/src/stores/modules/manufacturerStore.js @@ -0,0 +1,121 @@ +/** + * 制造商 + */ +import { ref } from 'vue'; +import { defineStore } from 'pinia'; +import http from '@/utils/http'; +import * as urls from '@/config/urls'; +import * as helpers from '@/utils/helpers'; + +export const useManufacturerStore = defineStore('manufacturer', () => { + const manufacturerList = ref([]); + const manufacturerExtra = ref({ total: null }); + const manufacturerQueries = ref({ page: 1, pageSize: 10, all: undefined, search: undefined }); + + function getManufacturerList(otherQueries = {}) { + return http.getInstance().get(urls.GET_MANUFACTURER_LIST, { + params: { ...manufacturerQueries.value, ...otherQueries }, + }).then(({ data }) => { + const { data: list, extra } = data; + manufacturerList.value = list || []; + manufacturerExtra.value = { ...manufacturerExtra.value, ...(extra || {}) }; + manufacturerQueries.value = { ...manufacturerQueries.value, ...otherQueries }; + return data; + }); + } + + function createManufacturer(formData = {}, { refreshList = false } = {}) { + const reqData = helpers.pick(formData, [ + 'manufacturerName', + 'manufacturerLogo', + 'contactPhone', + 'contactName', + // 'province', + // 'city', + // 'county', + // 'enterprise_name', + 'legal', + 'creditCode', + 'detailAddress', + 'email', + 'businessLicense', + 'companyLogo', + // 'idcardNum', + // 'idcardFrontPic', + // 'idcardReversePic', + // 'ruleProvince', + // 'ruleCity', + // 'ruleCounty', + ]); + + return http.getInstance().post(urls.CREATE_MANUFACTURER, reqData).then(({ data }) => { + if (refreshList) getManufacturerList(); + return data; + }); + } + + function updateManufacturer(formData = {}, { refreshList = false } = {}) { + const reqData = helpers.pick(formData, [ + 'id', + 'manufacturerName', + 'manufacturerLogo', + 'contactPhone', + 'contactName', + // 'province', + // 'city', + // 'county', + // 'enterprise_name', + 'legal', + 'creditCode', + 'detailAddress', + 'email', + 'businessLicense', + 'companyLogo', + // 'idcardNum', + // 'idcardFrontPic', + // 'idcardReversePic', + // 'ruleProvince', + // 'ruleCity', + // 'ruleCounty', + ]); + + const url = helpers.buildURL(urls.UPDATE_MANUFACTURER, reqData.id); + return http.getInstance().put(url, reqData).then(({ data }) => { + if (refreshList) getManufacturerList(); + return data; + }); + } + + function deleteManufacturer(manufacturerId = '', { refreshList = false } = {}) { + const url = helpers.buildURL(urls.DELETE_MANUFACTURER, manufacturerId); + return http.getInstance().delete(url).then(({ data }) => { + if (refreshList) getManufacturerList(); + return data; + }); + } + + function updateManufacturerDisable(formData = {}, { refreshList = false } = {}) { + const reqData = helpers.pick(formData, [ + 'id', + ]); + + const url = helpers.buildURL(urls.UPDATE_MANUFACTURER_DISABLE, reqData.id); + return http.getInstance().put(url).then(({ data }) => { + if (refreshList) getManufacturerList(); + return data; + }); + } + + return { + manufacturerList, + manufacturerQueries, + manufacturerExtra, + getManufacturerList, + createManufacturer, + updateManufacturer, + deleteManufacturer, + updateManufacturerDisable, + }; +}); + +export default null; diff --git a/src/stores/modules/noFlyZoneStore.js b/src/stores/modules/noFlyZoneStore.js new file mode 100644 index 0000000..fffa775 --- /dev/null +++ b/src/stores/modules/noFlyZoneStore.js @@ -0,0 +1,109 @@ +/** + * 禁飞区 + */ +import { ref } from 'vue'; +import { defineStore } from 'pinia'; +import http from '@/utils/http'; +import * as urls from '@/config/urls'; +import * as helpers from '@/utils/helpers'; + +export const useNoFlyZoneStore = defineStore('noFlyZone', () => { + const noFlyZoneList = ref([]); + const noFlyZoneExtra = ref({ total: null }); + const noFlyZoneQueries = ref({ page: 1, pageSize: 10, all: undefined, type: undefined, search: undefined }); + + const noFlyZoneDetail = ref({}); + + function getNoFlyZoneList(otherQueries = {}) { + return http.getInstance().get(urls.GET_NO_FLY_ZONE_LIST, { + params: { ...noFlyZoneQueries.value, ...otherQueries }, + }).then(({ data }) => { + const { data: list, extra } = data; + noFlyZoneList.value = list || []; + noFlyZoneExtra.value = { ...noFlyZoneExtra.value, ...(extra || {}) }; + noFlyZoneQueries.value = { ...noFlyZoneQueries.value, ...otherQueries }; + return data; + }); + } + + function getNoFlyZoneDetail(noFlyZoneId = '') { + const url = helpers.buildURL(urls.GET_NO_FLY_ZONE_DETAIL, noFlyZoneId); + return http.getInstance().get(url).then(({ data }) => { + const { data: detail } = data || {}; + noFlyZoneDetail.value = detail; + return data; + }); + } + + function createNoFlyZone(formData = {}, { refreshList = false } = {}) { + const reqData = helpers.pick(formData, [ + 'detailAddress', + 'effectTimeStart', + 'effectTimeEnd', + 'province', + // 'city', + // 'county', + 'orbit', + ]); + + return http.getInstance().post(urls.CREATE_NO_FLY_ZONE, reqData).then(({ data }) => { + if (refreshList) getNoFlyZoneList(); + return data; + }); + } + + function updateNoFlyZone(formData = {}, { refreshList = false } = {}) { + const reqData = helpers.pick(formData, [ + 'id', + 'detailAddress', + 'effectTimeStart', + 'effectTimeEnd', + 'province', + // 'city', + // 'county', + 'orbit', + 'isEnable', + ]); + + const url = helpers.buildURL(urls.UPDATE_NO_FLY_ZONE, reqData.id); + return http.getInstance().put(url, reqData).then(({ data }) => { + if (refreshList) getNoFlyZoneList(); + return data; + }); + } + + function deleteNoFlyZone(noFlyZoneId = '', { refreshList = false } = {}) { + const url = helpers.buildURL(urls.DELETE_NO_FLY_ZONE, noFlyZoneId); + return http.getInstance().delete(url).then(({ data }) => { + if (refreshList) getNoFlyZoneList(); + return data; + }); + } + + function updateNoFlyZoneState(formData = {}, { refreshList = false } = {}) { + const reqData = helpers.pick(formData, [ + 'id', + ]); + + const url = helpers.buildURL(urls.UPDATE_NO_FLY_ZONE_DISABLE, reqData.id); + return http.getInstance().put(url).then(({ data }) => { + if (refreshList) getNoFlyZoneList(); + return data; + }); + } + + return { + noFlyZoneList, + noFlyZoneQueries, + noFlyZoneExtra, + noFlyZoneDetail, + getNoFlyZoneList, + createNoFlyZone, + updateNoFlyZone, + deleteNoFlyZone, + updateNoFlyZoneState, + getNoFlyZoneDetail, + }; +}); + +export default null; diff --git a/src/stores/modules/regulatorStore.js b/src/stores/modules/regulatorStore.js new file mode 100644 index 0000000..f99a7c7 --- /dev/null +++ b/src/stores/modules/regulatorStore.js @@ -0,0 +1,51 @@ +/** + * 监管者 + */ +import { ref } from 'vue'; +import { defineStore } from 'pinia'; +import http from '@/utils/http'; +import * as urls from '@/config/urls'; +import * as helpers from '@/utils/helpers'; + +export const useRegulatorStore = defineStore('regulator', () => { + const regulatorList = ref([]); + const regulatorExtra = ref({ total: null }); + const regulatorQueries = ref({ page: 1, pageSize: 10, all: undefined, search: undefined }); + + function getRegulatorList(otherQueries = {}) { + return http.getInstance().get(urls.GET_REGULATOR_LIST, { + params: { ...regulatorQueries.value, ...otherQueries }, + }).then(({ data }) => { + const { data: list, extra } = data; + regulatorList.value = list || []; + regulatorExtra.value = { ...regulatorExtra.value, ...(extra || {}) }; + regulatorQueries.value = { ...regulatorQueries.value, ...otherQueries }; + return data; + }); + } + + function createRegulator(formData = {}, { refreshList = false } = {}) { + const reqData = helpers.pick(formData, [ + 'phone', + 'password', + ]); + + return http.getInstance().post(urls.CREATE_REGULATOR, reqData).then(({ data }) => { + if (refreshList) getRegulatorList(); + return data; + }); + } + + return { + regulatorList, + regulatorQueries, + regulatorExtra, + getRegulatorList, + createRegulator, + // updateRegulator, + // deleteRegulator, + // updateRegulatorDisable, + }; +}); + +export default null; diff --git a/src/styles/common.less b/src/styles/common.less index d8894d7..048010f 100644 --- a/src/styles/common.less +++ b/src/styles/common.less @@ -3,14 +3,125 @@ */ @import "variables"; +:root { + color-scheme: dark; +} + body, html, #app { margin: 0; padding: 0; height: 100%; + color: white; } .t-layout { background: transparent !important; } + +.mapboxgl-popup.shape-editor-popup, +.mapboxgl-popup.line-editor-popup { + .mapboxgl-popup-content { + background-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-top .mapboxgl-popup-tip { + border-bottom-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip { + border-top-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-left .mapboxgl-popup-tip { + border-right-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-right .mapboxgl-popup-tip { + border-left-color: fade(black, 60%); + } + + .form { + padding: 10px 10px 0 6px; + + & > div + div { + margin-top: 8px; + } + + .buttons { + padding-left: 3em; + } + + input { + border: 1px solid #2b6c5a; + background-color: fade(black, 30%); + padding: 2px 4px; + + &:focus { + outline: 1px solid #00ffcc; + } + } + + button { + background-color: #00ffcc; + border: none; + border-radius: 2px; + color: black; + cursor: pointer; + + & + button { + margin-left: 5px; + } + } + } +} + +.mapboxgl-popup.perception-popup { + .mapboxgl-popup-content { + padding: 0; + background-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-top .mapboxgl-popup-tip { + border-bottom-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip { + border-bottom-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip { + border-top-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip { + border-top-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-left .mapboxgl-popup-tip { + border-right-color: fade(black, 60%); + } + + &.mapboxgl-popup-anchor-right .mapboxgl-popup-tip { + border-left-color: fade(black, 60%); + } +} + +.mapboxgl-ctrl-bottom-left, +.mapboxgl-ctrl-bottom-right { + opacity: 0.2; + transition: opacity 0.2s ease; + + &:hover { + opacity: 1; + } +} + +.success-bg-color { + background-color: #52c41a !important; +} + +.danger-bg-color { + background-color: #f5222d !important; +} diff --git a/src/utils/UserInfo.js b/src/utils/UserInfo.js new file mode 100644 index 0000000..873891c --- /dev/null +++ b/src/utils/UserInfo.js @@ -0,0 +1,29 @@ +/** + * 用户信息 + */ + +class UserInfo { + static save(userInfo) { + localStorage.setItem('userInfo', JSON.stringify(userInfo)); + } + + static get() { + const userInfo = localStorage.getItem('userInfo'); + if (!userInfo) { + return {}; + } + try { + const result = JSON.parse(userInfo); + return typeof result === 'object' ? result : {}; + } catch (e) { + // + } + return {}; + } + + static remove() { + localStorage.removeItem('userInfo'); + } +} + +export default UserInfo; diff --git a/src/utils/commonRefs.js b/src/utils/commonRefs.js new file mode 100644 index 0000000..152a368 --- /dev/null +++ b/src/utils/commonRefs.js @@ -0,0 +1,46 @@ +/* + * 公共引用 + * 异步获取引用对象 + */ + +const refs = Symbol('refs'); +const pendingList = Symbol('pendingList'); + +class CommonRefs { + [refs] = {}; + + [pendingList] = {}; + + setRef(key, ref) { + if (key in this[refs]) return; + + this[refs][key] = ref; + const { [key]: resolves } = this[pendingList]; + (resolves || []).forEach((resolve, index) => { + if (!resolve) return; + resolve(ref); + resolves[index] = undefined; // 只作废,不删除 + }); + } + + getRef(key) { + return new Promise((resolve) => { + const { [key]: ref } = this[refs]; + if (ref) { + resolve(ref); + } else if (key in this[pendingList]) { + this[pendingList][key].push(resolve); + } else { + this[pendingList][key] = [resolve]; + } + }); + } + + removeRef(key) { + if (key in this[refs]) { + delete this[refs][key]; + } + } +} + +export default new CommonRefs(); diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..e317843 --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,180 @@ +/** + * 辅助函数 + */ +import moment from 'moment'; +import * as turf from '@turf/turf'; + +/** + * 等待异步结果 + * @param checker 检测函数,返回值为truthy时表示得到想要的结果,否则反之 + * @param timeout 指定时间内没有得到想要的结果则超时 + */ +export function until(checker = () => true, timeout = 2000) { + return new Promise((resolve) => { + let pollingTimer = null; + + const timeoutTimer = setTimeout(() => { + clearInterval(pollingTimer); + resolve(false); + }, timeout); + + pollingTimer = setInterval(() => { + const result = checker(); + if (result) { + clearTimeout(timeoutTimer); + clearInterval(pollingTimer); + resolve(result); + } + }, 10); + }); +} + +/** + * 根据restful的uri构建实际url + * @param uri uri模版 + * @param argv 插入到uri中的参数列表,按顺序插入 + * @returns url + */ +export function buildURL(uri, ...argv) { + return uri.replace(/{\w+}|:[a-zA-Z]+/g, () => { + const res = argv.shift(); + if (res === undefined) { + throw new Error('URI 参数不足'); + } + return res; + }); +} + +/** + * 选取原object指定属性,构成新的object + * @param originObject + * @param pickKeys + */ +export function pick(originObject, pickKeys = []) { + const newObject = {}; + pickKeys.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(originObject, key)) { + newObject[key] = originObject[key]; + } + }); + return newObject; +} + +/** + * 更新target对象的键值,使用source对象里的数据 + * @param target + * @param source + */ +export function update(target, source) { + const result = { ...target }; + Object.keys(target).forEach((key) => { + if (Object.prototype.hasOwnProperty.call(source, key)) { + result[key] = source[key]; + } + }); + return result; +} + +/** + * 将对象的部分key重命名 + * @param originObject 原始对象(也可以是相同结构的对象构成的数组) + * @param keysMapping 新keys映射(如{ name: 'username', sex: 'gender' },表示把原始对象中的name重命名为username、把sex重命名为gender,原始对象其他key不变) + * @returns {{}|*} 新对象 + */ +export function renameKeys(originObject, keysMapping = {}) { + if (!Array.isArray(originObject)) { + const kvList = Object.keys(originObject).map((key) => { + const newKey = keysMapping[key] || key; + return { [newKey]: originObject[key] }; + }); + return Object.assign({}, ...kvList); + } + return originObject.map((obj) => renameKeys(obj, keysMapping)); +} + +/** + * 随机数 + */ +export function rand(min, max, fraction = 0) { + const res = (Math.random() * ((max - min) + 1)) + min; + return res.toFixed(fraction) - 0; +} + +/** + * 将input当着数字处理,若不是数字则返回代替字符 + * @param input + * @param substitution + * @returns {string|number} + */ +export function numeric(input, substitution = '-') { + const type = typeof input; + if (type !== 'number' && type !== 'string') return substitution; + + const output = Number(input); + return !Number.isNaN(output) ? output : substitution; +} + +// 保留小数位(是整数则不保留) +export const toFixed = (num, digits = 2) => (Number(num || 0).toFixed(digits)) - 0; + +/** + * 格式化时间戳 + * @param timestamp + * @param format + * @returns {string} + */ +export function formatTime(timestamp, format = 'YYYY-MM-DD HH:mm:ss') { + return (timestamp || timestamp === 0) && moment(timestamp).isValid() ? moment(timestamp).format(format) : '-'; +} + +/** + * 将总秒数格式化为“x时y分z秒”形式 + * @param sec + * @param precision + * @param isChinese + * @returns {string} + */ +export function popularTime(sec, precision = 0, isChinese = false) { + const { HOUR, MINUTE, SECOND } = isChinese ? { HOUR: '时', MINUTE: '分', SECOND: '秒' } : { HOUR: 'h', MINUTE: 'm', SECOND: 's' }; + let remainingSec = sec; + const hours = Math.floor(remainingSec / 3600); + + remainingSec -= hours * 3600; + const minutes = Math.floor(remainingSec / 60); + + remainingSec -= minutes * 60; + + return `${hours ? `${hours}${HOUR}` : ''}${minutes ? `${minutes}${MINUTE}` : ''}${remainingSec ? `${remainingSec.toFixed(precision)}${SECOND}` : ''}`; +} + +/** + * 将秒转化为小时 + * @param sec + * @returns {number} + */ +export function toHour(sec) { + return (sec / 3600).toFixed(2) - 0; +} + +/** + * 对数组中的每个元素进行偏移计算,返回计算后的数组 + * @param originArray 原数组(有数字构成) + * @param offsetArray 偏移量数组(与原数组元素一一对应) + * @returns {*[]} 计算后的新数组 + */ +export function arrOffset(originArray = [], offsetArray = []) { + return originArray.map((item, index) => +item + (offsetArray[index] || 0)); +} + +/** + * 计算经纬度构成的区域面积 + * @param coordinates 经纬度点列表 + * @param unit 单位(默认:亩) + * @returns {number} 面积值(平方米) + */ +export function computeArea(coordinates, unit = 'mu') { + const lineFeature = turf.lineString(coordinates); + const polygonFeature = turf.lineToPolygon(lineFeature); + const { [unit]: rate } = { mu: 0.0015 }; + return turf.area(polygonFeature) * rate; +} diff --git a/src/views/LoginView/LoginView.vue b/src/views/LoginView/LoginView.vue index 166dc65..6fcc9da 100644 --- a/src/views/LoginView/LoginView.vue +++ b/src/views/LoginView/LoginView.vue @@ -1,13 +1,136 @@ diff --git a/src/views/ManufacturerView/ManufacturerView.vue b/src/views/ManufacturerView/ManufacturerView.vue new file mode 100644 index 0000000..8b0eb3c --- /dev/null +++ b/src/views/ManufacturerView/ManufacturerView.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/views/ManufacturerView/components/ManufacturerDetail.vue b/src/views/ManufacturerView/components/ManufacturerDetail.vue new file mode 100644 index 0000000..1bd1c4c --- /dev/null +++ b/src/views/ManufacturerView/components/ManufacturerDetail.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/views/ManufacturerView/components/ManufacturerEditor.vue b/src/views/ManufacturerView/components/ManufacturerEditor.vue new file mode 100644 index 0000000..7356fa0 --- /dev/null +++ b/src/views/ManufacturerView/components/ManufacturerEditor.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/src/views/NoFlyZoneEditorView/NoFlyZoneEditorView.vue b/src/views/NoFlyZoneEditorView/NoFlyZoneEditorView.vue new file mode 100644 index 0000000..764dde9 --- /dev/null +++ b/src/views/NoFlyZoneEditorView/NoFlyZoneEditorView.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/src/views/NoFlyZoneView/NoFlyZoneView.vue b/src/views/NoFlyZoneView/NoFlyZoneView.vue new file mode 100644 index 0000000..6c8c894 --- /dev/null +++ b/src/views/NoFlyZoneView/NoFlyZoneView.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/src/views/RegulatorView/RegulatorView.vue b/src/views/RegulatorView/RegulatorView.vue new file mode 100644 index 0000000..f435cda --- /dev/null +++ b/src/views/RegulatorView/RegulatorView.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/src/views/RegulatorView/components/RegulatorEditor.vue b/src/views/RegulatorView/components/RegulatorEditor.vue new file mode 100644 index 0000000..1af9c49 --- /dev/null +++ b/src/views/RegulatorView/components/RegulatorEditor.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/views/common/useGlobalSettings.js b/src/views/common/useGlobalSettings.js new file mode 100644 index 0000000..3e31754 --- /dev/null +++ b/src/views/common/useGlobalSettings.js @@ -0,0 +1,23 @@ +/** + * 本地持久化配置项 + */ +import { toValue } from 'vue'; +import { createGlobalState, useStorage } from '@vueuse/core'; + +export const useGlobalSettings = createGlobalState(() => { + const settings = useStorage('settings', { + showMapHd: true, + showFieldFill: true, + showFieldName: true, + }); + + const valueOf = () => toValue(settings); + + const set = (key, val) => { + if (key in settings.value) settings.value[key] = val; + }; + + return { set, valueOf }; +}); + +export default null;