mirror of
https://github.com/haiwen/seahub.git
synced 2025-04-27 02:51:00 +00:00
Feature/add map view (#7034)
* add map * use custom baidu js * use custom baidu plugin * optimize user location * optimize code * show tips when request user location failed * optimize code * feat: optimize code * feat: optimize code * feat: optimize code * feat: optimize code --------- Co-authored-by: zhouwenxuan <aries@Mac.local> Co-authored-by: 杨国璇 <ygx@Hello-word.local>
This commit is contained in:
parent
29d2d18a0b
commit
3b384be610
20
frontend/src/assets/icons/map.svg
Normal file
20
frontend/src/assets/icons/map.svg
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#999999;}
|
||||
</style>
|
||||
<title>map</title>
|
||||
<g id="map">
|
||||
<g id="形状结合" transform="translate(1.000000, 1.000000)">
|
||||
<path class="st0" d="M29.5,6.8C29.8,7,30,7.4,30,7.8v17.6c0,0.5-0.3,1-0.8,1.2l-8.8,3.3c-0.3,0.1-0.6,0.1-0.8,0L10,26.7l-8.3,3.2
|
||||
c-0.4,0.1-0.8,0.1-1.2-0.1c-0.3-0.2-0.5-0.6-0.5-1V11.2c0-0.5,0.3-1,0.8-1.2l4-1.5c-0.2,1.1-0.2,2.3,0,3.4l-2,0.9v13.4l5.6-2.4
|
||||
l0-5.4l2.8,3.5v2.7l7.5,2.5v-5.2l2.8-3.5l0,8l5.6-2.2V10.4l-1.9,0.8c0.1-1.1,0.1-2.2-0.2-3.3l3.2-1.2C28.7,6.5,29.1,6.6,29.5,6.8z
|
||||
M8.3,2.7c3.7-3.6,9.6-3.6,13.3,0c3.3,3.1,3.7,8,1.1,11.7l-6.2,7.7c-0.1,0.1-0.2,0.2-0.3,0.3c-0.9,0.7-2.1,0.6-2.8-0.3l-6.1-7.6
|
||||
C4.7,10.8,5.2,5.9,8.3,2.7z M19.4,4.8c-2.4-2.2-6.4-2.2-8.8,0c-2.2,2-2.4,5.1-0.7,7.4l4.1,5.5c0.5,0.7,1.5,0.7,2,0l4.1-5.5
|
||||
C21.8,9.9,21.6,6.8,19.4,4.8z M15,5c2.2,0,4,1.8,4,4c0,1.4-0.8,2.7-2,3.5s-2.8,0.7-4,0s-2-2-2-3.5C11,6.8,12.8,5,15,5z M15,8
|
||||
c-0.6,0-1,0.4-1,1s0.4,1,1,1s1-0.4,1-1S15.6,8,15,8z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
@ -19,6 +19,10 @@ const VIEW_OPTIONS = [
|
||||
{
|
||||
key: 'kanban',
|
||||
type: VIEW_TYPE.KANBAN,
|
||||
},
|
||||
{
|
||||
key: 'map',
|
||||
type: VIEW_TYPE.MAP,
|
||||
}
|
||||
];
|
||||
|
||||
@ -49,6 +53,8 @@ const AddView = ({ target, toggle, onOptionClick }) => {
|
||||
return gettext('Gallery');
|
||||
case VIEW_TYPE.KANBAN:
|
||||
return gettext('Kanban');
|
||||
case VIEW_TYPE.MAP:
|
||||
return gettext('Map');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import TableViewToolbar from './table-view-toolbar';
|
||||
import GalleryViewToolbar from './gallery-view-toolbar';
|
||||
import FaceRecognitionViewToolbar from './face-recognition';
|
||||
import KanbanViewToolBar from './kanban-view-toolbar';
|
||||
import MapViewToolBar from './map-view-toolbar';
|
||||
|
||||
import './index.css';
|
||||
|
||||
@ -110,6 +111,14 @@ const ViewToolBar = ({ viewId, isCustomPermission, showDetail, closeDetail }) =>
|
||||
closeDetail={closeDetail}
|
||||
/>
|
||||
)}
|
||||
{viewType === VIEW_TYPE.MAP && (
|
||||
<MapViewToolBar
|
||||
readOnly={readOnly}
|
||||
view={view}
|
||||
collaborators={collaborators}
|
||||
modifyFilters={modifyFilters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,52 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { PRIVATE_COLUMN_KEY } from '../../../constants';
|
||||
import { FilterSetter } from '../../data-process-setter';
|
||||
|
||||
const MapViewToolBar = ({
|
||||
readOnly,
|
||||
view,
|
||||
collaborators,
|
||||
modifyFilters,
|
||||
}) => {
|
||||
const viewType = useMemo(() => view.type, [view]);
|
||||
const viewColumns = useMemo(() => {
|
||||
if (!view) return [];
|
||||
return view.columns;
|
||||
}, [view]);
|
||||
|
||||
const filterColumns = useMemo(() => {
|
||||
return viewColumns.filter(c => c.key !== PRIVATE_COLUMN_KEY.FILE_TYPE);
|
||||
}, [viewColumns]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sf-metadata-tool-left-operations">
|
||||
<FilterSetter
|
||||
isNeedSubmit={true}
|
||||
wrapperClass="sf-metadata-view-tool-operation-btn sf-metadata-view-tool-filter"
|
||||
filtersClassName="sf-metadata-filters"
|
||||
target="sf-metadata-filter-popover"
|
||||
readOnly={readOnly}
|
||||
filterConjunction={view.filter_conjunction}
|
||||
basicFilters={view.basic_filters}
|
||||
filters={view.filters}
|
||||
columns={filterColumns}
|
||||
modifyFilters={modifyFilters}
|
||||
collaborators={collaborators}
|
||||
viewType={viewType}
|
||||
/>
|
||||
</div>
|
||||
<div className="sf-metadata-tool-right-operations"></div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
MapViewToolBar.propTypes = {
|
||||
readOnly: PropTypes.bool,
|
||||
view: PropTypes.object,
|
||||
collaborators: PropTypes.array,
|
||||
modifyFilters: PropTypes.func,
|
||||
};
|
||||
|
||||
export default MapViewToolBar;
|
@ -9,6 +9,7 @@ export const VIEW_TYPE = {
|
||||
GALLERY: 'gallery',
|
||||
FACE_RECOGNITION: 'face_recognition',
|
||||
KANBAN: 'kanban',
|
||||
MAP: 'map',
|
||||
};
|
||||
|
||||
export const FACE_RECOGNITION_VIEW_ID = '_face_recognition';
|
||||
@ -18,6 +19,7 @@ export const VIEW_TYPE_ICON = {
|
||||
[VIEW_TYPE.GALLERY]: 'image',
|
||||
[VIEW_TYPE.FACE_RECOGNITION]: 'face-recognition-view',
|
||||
[VIEW_TYPE.KANBAN]: 'kanban',
|
||||
[VIEW_TYPE.MAP]: 'map',
|
||||
'image': 'image'
|
||||
};
|
||||
|
||||
@ -52,24 +54,38 @@ export const VIEW_TYPE_DEFAULT_BASIC_FILTER = {
|
||||
filter_term: []
|
||||
},
|
||||
],
|
||||
[VIEW_TYPE.MAP]: [
|
||||
{
|
||||
column_key: PRIVATE_COLUMN_KEY.IS_DIR,
|
||||
filter_predicate: FILTER_PREDICATE_TYPE.IS,
|
||||
filter_term: 'file'
|
||||
}, {
|
||||
column_key: PRIVATE_COLUMN_KEY.FILE_TYPE,
|
||||
filter_predicate: FILTER_PREDICATE_TYPE.IS_ANY_OF,
|
||||
filter_term: ['_picture']
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const VIEW_TYPE_DEFAULT_SORTS = {
|
||||
[VIEW_TYPE.TABLE]: [],
|
||||
[VIEW_TYPE.GALLERY]: [{ column_key: PRIVATE_COLUMN_KEY.FILE_CTIME, sort_type: SORT_TYPE.DOWN }],
|
||||
[VIEW_TYPE.KANBAN]: [],
|
||||
[VIEW_TYPE.MAP]: [],
|
||||
};
|
||||
|
||||
export const VIEW_SORT_COLUMN_RULES = {
|
||||
[VIEW_TYPE.TABLE]: (column) => SORT_COLUMN_OPTIONS.includes(column.type),
|
||||
[VIEW_TYPE.GALLERY]: (column) => GALLERY_SORT_COLUMN_OPTIONS.includes(column.type) || GALLERY_SORT_PRIVATE_COLUMN_KEYS.includes(column.key),
|
||||
[VIEW_TYPE.KANBAN]: (column) => SORT_COLUMN_OPTIONS.includes(column.type),
|
||||
[VIEW_TYPE.MAP]: () => {},
|
||||
};
|
||||
|
||||
export const VIEW_FIRST_SORT_COLUMN_RULES = {
|
||||
[VIEW_TYPE.TABLE]: (column) => SORT_COLUMN_OPTIONS.includes(column.type),
|
||||
[VIEW_TYPE.GALLERY]: (column) => GALLERY_FIRST_SORT_COLUMN_OPTIONS.includes(column.type) || GALLERY_FIRST_SORT_PRIVATE_COLUMN_KEYS.includes(column.key),
|
||||
[VIEW_TYPE.KANBAN]: (column) => SORT_COLUMN_OPTIONS.includes(column.type),
|
||||
[VIEW_TYPE.MAP]: () => {},
|
||||
};
|
||||
|
||||
export const KANBAN_SETTINGS_KEYS = {
|
||||
|
@ -32,6 +32,9 @@ const updateFavicon = (type) => {
|
||||
case VIEW_TYPE.KANBAN:
|
||||
favicon.href = `${mediaUrl}favicons/kanban.png`;
|
||||
break;
|
||||
case VIEW_TYPE.MAP:
|
||||
favicon.href = `${mediaUrl}favicons/map.png`;
|
||||
break;
|
||||
default:
|
||||
favicon.href = `${mediaUrl}favicons/favicon.png`;
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import Table from '../views/table';
|
||||
import Gallery from '../views/gallery';
|
||||
import FaceRecognition from '../views/face-recognition';
|
||||
import Kanban from '../views/kanban';
|
||||
import Map from '../views/map';
|
||||
import { useMetadataView } from '../hooks/metadata-view';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import { VIEW_TYPE } from '../constants';
|
||||
@ -27,6 +28,9 @@ const View = () => {
|
||||
case VIEW_TYPE.KANBAN: {
|
||||
return <Kanban />;
|
||||
}
|
||||
case VIEW_TYPE.MAP: {
|
||||
return <Map />;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@ -7,9 +7,9 @@ import { getRowsByIds } from '../utils/table';
|
||||
import { isGroupView } from '../utils/view';
|
||||
import { username } from '../../utils/constants';
|
||||
import { COLUMN_DATA_OPERATION_TYPE, OPERATION_TYPE } from './operations';
|
||||
import { CellType, PRIVATE_COLUMN_KEY } from '../constants';
|
||||
import { CellType } from '../constants';
|
||||
import { getCellValueByColumn, getOption, isValidCellValue, checkIsPredefinedOption, getColumnOptionIdsByNames,
|
||||
getColumnOptionNamesByIds
|
||||
getColumnOptionNamesByIds, geRecordIdFromRecord,
|
||||
} from '../utils/cell';
|
||||
|
||||
// const DEFAULT_COMPUTER_PROPERTIES_CONTROLLER = {
|
||||
@ -181,7 +181,8 @@ class DataProcessor {
|
||||
const newOptionNames = getColumnOptionNamesByIds(newColumn, oldOptionIds);
|
||||
row[columnOriginalName] = newOptionNames ? newOptionNames : null;
|
||||
}
|
||||
table.id_row_map[row[PRIVATE_COLUMN_KEY.ID]] = row;
|
||||
const id = geRecordIdFromRecord(row);
|
||||
table.id_row_map[id] = row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { PRIVATE_COLUMN_KEY, UTC_FORMAT_DEFAULT } from '../../constants';
|
||||
import { UTC_FORMAT_DEFAULT } from '../../constants';
|
||||
import { OPERATION_TYPE } from './constants';
|
||||
import Column from '../../model/metadata/column';
|
||||
import View from '../../model/metadata/view';
|
||||
import { getColumnOriginName } from '../../utils/column';
|
||||
import { geRecordIdFromRecord } from '../../utils/cell';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@ -141,8 +142,9 @@ export default function apply(data, operation) {
|
||||
let id_row_map = {};
|
||||
data.rows.forEach(row => {
|
||||
delete row[columnOriginName];
|
||||
const id = geRecordIdFromRecord(row);
|
||||
rows.push(row);
|
||||
id_row_map[row[PRIVATE_COLUMN_KEY.ID]] = row;
|
||||
id_row_map[id] = row;
|
||||
});
|
||||
data.id_row_map = id_row_map;
|
||||
delete data.key_column_map[column_key];
|
||||
|
@ -13,40 +13,6 @@ const getAutoTimeDisplayString = (autoTime) => {
|
||||
return date.format('YYYY-MM-DD HH:mm:ss');
|
||||
};
|
||||
|
||||
export const getClientCellValueDisplayString = (record, column, { collaborators = [] } = {}) => {
|
||||
const cellValue = getCellValueByColumn(record, column);
|
||||
const { type } = column;
|
||||
if (type === CellType.CTIME || type === CellType.MTIME) {
|
||||
return getAutoTimeDisplayString(cellValue);
|
||||
}
|
||||
return getCellValueDisplayString(record, column, { collaborators });
|
||||
};
|
||||
|
||||
export const getFormatRecordData = (columns, recordData) => {
|
||||
let keyColumnMap = {};
|
||||
columns.forEach(column => {
|
||||
keyColumnMap[column.key] = column;
|
||||
});
|
||||
return convertedToRecordData(recordData, keyColumnMap);
|
||||
};
|
||||
|
||||
export const getFormattedRecordsData = (recordsData, columns, excludesColumnTypes) => {
|
||||
let keyColumnMap = {};
|
||||
columns.forEach(column => {
|
||||
keyColumnMap[column.key] = column;
|
||||
});
|
||||
return recordsData.map(recordData => {
|
||||
let formattedRecordsData = convertedToRecordData(recordData, keyColumnMap, excludesColumnTypes);
|
||||
if (recordData._id) {
|
||||
formattedRecordsData._id = recordData._id;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(recordData, '_archived')) {
|
||||
formattedRecordsData._archived = recordData._archived ? 'true' : 'false';
|
||||
}
|
||||
return formattedRecordsData;
|
||||
});
|
||||
};
|
||||
|
||||
// { [column.key]: cellValue } -> { [column.name]: cellValue }
|
||||
// { [option-column.key]: option.id } -> { [option-column.name]: option.name }
|
||||
function convertedToRecordData(originRecordData, keyColumnMap, excludesColumnTypes = []) {
|
||||
@ -80,3 +46,37 @@ function convertedToRecordData(originRecordData, keyColumnMap, excludesColumnTyp
|
||||
});
|
||||
return recordData;
|
||||
}
|
||||
|
||||
export const getClientCellValueDisplayString = (record, column, { collaborators = [] } = {}) => {
|
||||
const cellValue = getCellValueByColumn(record, column);
|
||||
const { type } = column;
|
||||
if (type === CellType.CTIME || type === CellType.MTIME) {
|
||||
return getAutoTimeDisplayString(cellValue);
|
||||
}
|
||||
return getCellValueDisplayString(record, column, { collaborators });
|
||||
};
|
||||
|
||||
export const getFormatRecordData = (columns, recordData) => {
|
||||
let keyColumnMap = {};
|
||||
columns.forEach(column => {
|
||||
keyColumnMap[column.key] = column;
|
||||
});
|
||||
return convertedToRecordData(recordData, keyColumnMap);
|
||||
};
|
||||
|
||||
export const getFormattedRecordsData = (recordsData, columns, excludesColumnTypes) => {
|
||||
let keyColumnMap = {};
|
||||
columns.forEach(column => {
|
||||
keyColumnMap[column.key] = column;
|
||||
});
|
||||
return recordsData.map(recordData => {
|
||||
let formattedRecordsData = convertedToRecordData(recordData, keyColumnMap, excludesColumnTypes);
|
||||
if (recordData._id) {
|
||||
formattedRecordsData._id = recordData._id;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(recordData, '_archived')) {
|
||||
formattedRecordsData._archived = recordData._archived ? 'true' : 'false';
|
||||
}
|
||||
return formattedRecordsData;
|
||||
});
|
||||
};
|
||||
|
@ -39,3 +39,11 @@ export const geRecordIdFromRecord = record => {
|
||||
export const getFileObjIdFromRecord = record => {
|
||||
return record ? record[PRIVATE_COLUMN_KEY.OBJ_ID] : '';
|
||||
};
|
||||
|
||||
export const getImageLocationFromRecord = (record) => {
|
||||
return record ? record[PRIVATE_COLUMN_KEY.LOCATION] : null;
|
||||
};
|
||||
|
||||
export const getFileTypeFromRecord = (record) => {
|
||||
return record ? record[PRIVATE_COLUMN_KEY.FILE_TYPE] : null;
|
||||
};
|
||||
|
@ -1,42 +1,5 @@
|
||||
export {
|
||||
isValidCellValue,
|
||||
getCellValueByColumn,
|
||||
getParentDirFromRecord,
|
||||
getFileNameFromRecord,
|
||||
geRecordIdFromRecord,
|
||||
getFileObjIdFromRecord,
|
||||
} from './core';
|
||||
|
||||
export {
|
||||
getCellValueDisplayString,
|
||||
getCellValueStringResult,
|
||||
} from './common';
|
||||
|
||||
export {
|
||||
getDateDisplayString,
|
||||
getPrecisionNumber,
|
||||
getNumberDisplayString,
|
||||
replaceNumberNotAllowInput,
|
||||
formatStringToNumber,
|
||||
formatTextToNumber,
|
||||
checkIsPredefinedOption,
|
||||
getOption,
|
||||
getColumnOptionNameById,
|
||||
getOptionName,
|
||||
getMultipleOptionName,
|
||||
getCollaborator,
|
||||
getCollaboratorsNames,
|
||||
getCollaboratorsName,
|
||||
getCollaboratorEmailsByNames,
|
||||
getLongtextDisplayString,
|
||||
getGeolocationDisplayString,
|
||||
getGeolocationByGranularity,
|
||||
getFloatNumber,
|
||||
getColumnOptionNamesByIds,
|
||||
getColumnOptionIdsByNames,
|
||||
decimalToExposureTime,
|
||||
} from './column';
|
||||
|
||||
export { isCellValueChanged } from './cell-comparer';
|
||||
|
||||
export { getClientCellValueDisplayString } from './cell-format-utils';
|
||||
export * from './core';
|
||||
export * from './common';
|
||||
export * from './column';
|
||||
export * from './cell-comparer';
|
||||
export * from './cell-format-utils';
|
||||
|
46
frontend/src/metadata/views/map/custom-avatar-overlay.js
Normal file
46
frontend/src/metadata/views/map/custom-avatar-overlay.js
Normal file
@ -0,0 +1,46 @@
|
||||
const customAvatarOverlay = (point, avatarUrl, bgUrl, width = 20, height = 25) => {
|
||||
class AvatarOverlay extends window.BMap.Overlay {
|
||||
constructor(point, avatarUrl, bgUrl, width, height) {
|
||||
super();
|
||||
this._point = point;
|
||||
this._headerImg = avatarUrl;
|
||||
this._bgUrl = bgUrl;
|
||||
this._width = width;
|
||||
this._height = height;
|
||||
}
|
||||
|
||||
initialize(map) {
|
||||
this._map = map;
|
||||
const divBox = document.createElement('div');
|
||||
const divImg = new Image();
|
||||
divBox.style.position = 'absolute';
|
||||
divBox.style.width = `${this._width}px`;
|
||||
divBox.style.height = `${this._height}px`;
|
||||
divBox.style.backgroundImage = `url(${this._bgUrl})`;
|
||||
divBox.style.backgroundPosition = '.5px 0px';
|
||||
divBox.style.display = 'flex';
|
||||
divBox.style.padding = '2px 2.5px 0 2px';
|
||||
|
||||
divImg.src = this._headerImg;
|
||||
divImg.style.width = '16px';
|
||||
divImg.style.height = '16px';
|
||||
divImg.style.borderRadius = '50%';
|
||||
divImg.style.display = 'block';
|
||||
divBox.appendChild(divImg);
|
||||
|
||||
map.getPanes().markerPane.appendChild(divBox);
|
||||
this._div = divBox;
|
||||
return divBox;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const position = this._map.pointToOverlayPixel(this._point);
|
||||
this._div.style.left = `${position.x - this._width / 2}px`;
|
||||
this._div.style.top = `${position.y - (this._height * 7) / 10}px`;
|
||||
}
|
||||
}
|
||||
|
||||
return new AvatarOverlay(point, avatarUrl, bgUrl, width, height);
|
||||
};
|
||||
|
||||
export default customAvatarOverlay;
|
69
frontend/src/metadata/views/map/custom-image-overlay.js
Normal file
69
frontend/src/metadata/views/map/custom-image-overlay.js
Normal file
@ -0,0 +1,69 @@
|
||||
import { Utils } from '../../../utils/utils';
|
||||
|
||||
const customImageOverlay = (center, imageUrl) => {
|
||||
class ImageOverlay extends window.BMap.Overlay {
|
||||
constructor(center, imageUrl) {
|
||||
super();
|
||||
this._center = center;
|
||||
this._imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
initialize(map) {
|
||||
this._map = map;
|
||||
const div = document.createElement('div');
|
||||
div.style.position = 'absolute';
|
||||
div.style.width = '80px';
|
||||
div.style.height = '80px';
|
||||
div.style.zIndex = 2000;
|
||||
map.getPanes().markerPane.appendChild(div);
|
||||
this._div = div;
|
||||
|
||||
const imageElement = `<img src=${this._imageUrl} width="72" height="72" />`;
|
||||
const htmlString =
|
||||
`
|
||||
<div class="custom-image-container">
|
||||
${this._imageUrl ? imageElement : '<div class="empty-custom-image-wrapper"></div>'}
|
||||
<i class='sf3-font image-overlay-arrow'></i>
|
||||
</div>
|
||||
`;
|
||||
const labelDocument = new DOMParser().parseFromString(htmlString, 'text/html');
|
||||
const label = labelDocument.body.firstElementChild;
|
||||
this._div.append(label);
|
||||
|
||||
const eventHandler = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
if (Utils.isDesktop()) {
|
||||
this._div.addEventListener('click', eventHandler);
|
||||
} else {
|
||||
this._div.addEventListener('touchend', eventHandler);
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const position = this._map.pointToOverlayPixel(this._center);
|
||||
this._div.style.left = position.x - 40 + 'px'; // 40 is 1/2 container height
|
||||
this._div.style.top = position.y - 88 + 'px'; // 80 is container height and 8 is icon height
|
||||
}
|
||||
|
||||
getImageUrl() {
|
||||
return imageUrl || '';
|
||||
}
|
||||
|
||||
getPosition() {
|
||||
return center;
|
||||
}
|
||||
|
||||
getMap() {
|
||||
return this._map || null;
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageOverlay(center, imageUrl);
|
||||
};
|
||||
|
||||
export default customImageOverlay;
|
50
frontend/src/metadata/views/map/geolocation-control.js
Normal file
50
frontend/src/metadata/views/map/geolocation-control.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { mediaUrl } from '../../../utils/constants';
|
||||
import { Utils } from '../../../utils/utils';
|
||||
|
||||
export function createBMapGeolocationControl(BMap, callback) {
|
||||
function GeolocationControl() {
|
||||
this.defaultAnchor = window.BMAP_ANCHOR_BOTTOM_RIGHT;
|
||||
this.defaultOffset = new BMap.Size(10, Utils.isDesktop() ? 20 : 90);
|
||||
}
|
||||
GeolocationControl.prototype = new window.BMap.Control();
|
||||
GeolocationControl.prototype.initialize = function (map) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'sf-BMap-geolocation-control';
|
||||
div.style = 'display: flex; justify-content: center; align-items: center;';
|
||||
|
||||
const icon = document.createElement('img');
|
||||
icon.className = 'sf-BMap-icon-current-location';
|
||||
icon.src = `${mediaUrl}/img/current-location.svg`;
|
||||
icon.style = 'width: 16px; height: 16px; display: block;';
|
||||
div.appendChild(icon);
|
||||
if (Utils.isDesktop()) {
|
||||
setNodeStyle(div, 'height: 30px; width: 30px; line-height: 30px');
|
||||
} else {
|
||||
setNodeStyle(div, 'height: 35px; width: 35px; line-height: 35px; opacity: 0.75');
|
||||
}
|
||||
div.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
const geolocation = new BMap.Geolocation();
|
||||
div.className = 'sf-BMap-geolocation-control sf-BMap-geolocation-control-loading';
|
||||
geolocation.getCurrentPosition((result) => {
|
||||
div.className = 'sf-BMap-geolocation-control';
|
||||
if (result) {
|
||||
const point = result.point;
|
||||
map.setCenter(point);
|
||||
callback(null, point);
|
||||
} else {
|
||||
// Positioning failed
|
||||
callback(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
map.getContainer().appendChild(div);
|
||||
return div;
|
||||
};
|
||||
|
||||
return GeolocationControl;
|
||||
}
|
||||
|
||||
function setNodeStyle(dom, styleText) {
|
||||
dom.style.cssText += styleText;
|
||||
}
|
85
frontend/src/metadata/views/map/index.css
Normal file
85
frontend/src/metadata/views/map/index.css
Normal file
@ -0,0 +1,85 @@
|
||||
.sf-metadata-view-map {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sf-metadata-view-map .sf-metadata-map-container {
|
||||
width: 100%;;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sf-metadata-view-map .custom-image-container {
|
||||
padding: 4px;
|
||||
background: #fff;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
cursor: default;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 10%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sf-metadata-view-map .custom-image-number {
|
||||
position: absolute;
|
||||
right: -15px;
|
||||
top: -8px;
|
||||
padding: 0 12px;
|
||||
background: #20AD7E;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.sf-metadata-view-map .custom-image-container .plugin-label-arrow {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
transform: translate( -50%, 100%);
|
||||
left: 50%;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
line-height: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.sf-metadata-view-map .custom-image-container .image-overlay-arrow {
|
||||
bottom: 5px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
height: 16px;
|
||||
left: 50%;
|
||||
line-height: 16px;
|
||||
position: absolute;
|
||||
transform: translate(-50%, 100%);
|
||||
}
|
||||
|
||||
.sf-metadata-view-map .custom-image-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 10px solid transparent;
|
||||
border-right: 10px solid transparent;
|
||||
border-top: 10px solid #fff;
|
||||
}
|
||||
|
||||
.sf-metadata-view-map .sf-BMap-geolocation-control {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 0 4px rgb(0 0 0 / 12%);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.sf-metadata-view-map .sf-BMap-geolocation-control-loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sf-metadata-view-map .sf-BMap-geolocation-control:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
171
frontend/src/metadata/views/map/index.js
Normal file
171
frontend/src/metadata/views/map/index.js
Normal file
@ -0,0 +1,171 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
|
||||
import loadBMap, { initMapInfo } from '../../../utils/map-utils';
|
||||
import { wgs84_to_gcj02, gcj02_to_bd09 } from '../../../utils/coord-transform';
|
||||
import { MAP_TYPE } from '../../../constants';
|
||||
import { isValidPosition } from '../../utils/validate';
|
||||
import { appAvatarURL, baiduMapKey, gettext, googleMapKey, mediaUrl, siteRoot, thumbnailSizeForGrid } from '../../../utils/constants';
|
||||
import { useMetadataView } from '../../hooks/metadata-view';
|
||||
import { PREDEFINED_FILE_TYPE_OPTION_KEY } from '../../constants';
|
||||
import { geRecordIdFromRecord, getFileNameFromRecord, getImageLocationFromRecord, getParentDirFromRecord,
|
||||
getFileTypeFromRecord
|
||||
} from '../../utils/cell';
|
||||
import { Utils } from '../../../utils/utils';
|
||||
import customImageOverlay from './custom-image-overlay';
|
||||
import customAvatarOverlay from './custom-avatar-overlay';
|
||||
import { createBMapGeolocationControl } from './geolocation-control';
|
||||
import toaster from '../../../components/toast';
|
||||
|
||||
import './index.css';
|
||||
|
||||
const DEFAULT_POSITION = { lng: 104.195, lat: 35.861 };
|
||||
const DEFAULT_ZOOM = 4;
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
const Map = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const mapRef = useRef(null);
|
||||
const clusterRef = useRef(null);
|
||||
const batchIndexRef = useRef(0);
|
||||
|
||||
const { metadata } = useMetadataView();
|
||||
|
||||
const mapInfo = useMemo(() => initMapInfo({ baiduMapKey, googleMapKey }), []);
|
||||
const repoID = window.sfMetadataContext.getSetting('repoID');
|
||||
|
||||
const validImages = useMemo(() => {
|
||||
return metadata.rows
|
||||
.map(record => {
|
||||
const recordType = getFileTypeFromRecord(record);
|
||||
if (recordType !== PREDEFINED_FILE_TYPE_OPTION_KEY.PICTURE) return null;
|
||||
const id = geRecordIdFromRecord(record);
|
||||
const fileName = getFileNameFromRecord(record);
|
||||
const parentDir = getParentDirFromRecord(record);
|
||||
const path = Utils.encodePath(Utils.joinPath(parentDir, fileName));
|
||||
const src = `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`;
|
||||
const location = getImageLocationFromRecord(record);
|
||||
if (!location) return null;
|
||||
const { lng, lat } = location;
|
||||
return isValidPosition(lng, lat) ? { id, src, lng, lat } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [repoID, metadata]);
|
||||
|
||||
const addMapController = useCallback(() => {
|
||||
var navigation = new window.BMap.NavigationControl();
|
||||
const GeolocationControl = createBMapGeolocationControl(window.BMap, (err, point) => {
|
||||
if (!err && point) {
|
||||
mapRef.current.setCenter({ lng: point.lng, lat: point.lat });
|
||||
}
|
||||
});
|
||||
const geolocationControl = new GeolocationControl();
|
||||
mapRef.current.addControl(geolocationControl);
|
||||
mapRef.current.addControl(navigation);
|
||||
}, []);
|
||||
|
||||
const renderMarkersBatch = useCallback(() => {
|
||||
if (!validImages.length || !clusterRef.current) return;
|
||||
|
||||
const startIndex = batchIndexRef.current * BATCH_SIZE;
|
||||
const endIndex = Math.min(startIndex + BATCH_SIZE, validImages.length);
|
||||
const batchMarkers = [];
|
||||
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const image = validImages[i];
|
||||
const { lng, lat } = image;
|
||||
const point = new window.BMap.Point(lng, lat);
|
||||
const marker = customImageOverlay(point, image.src);
|
||||
batchMarkers.push(marker);
|
||||
}
|
||||
|
||||
clusterRef.current.addMarkers(batchMarkers);
|
||||
|
||||
if (endIndex < validImages.length) {
|
||||
batchIndexRef.current += 1;
|
||||
setTimeout(renderMarkersBatch, 20); // Schedule the next batch
|
||||
}
|
||||
}, [validImages]);
|
||||
|
||||
const initializeClusterer = useCallback(() => {
|
||||
if (mapRef.current && !clusterRef.current) {
|
||||
clusterRef.current = new window.BMapLib.MarkerClusterer(mapRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initializeUserMarker = useCallback(() => {
|
||||
if (!window.BMap) return;
|
||||
|
||||
const imageUrl = `${mediaUrl}/img/marker.png`;
|
||||
const addMarker = (lng, lat) => {
|
||||
const point = new window.BMap.Point(lng, lat);
|
||||
const avatarMarker = customAvatarOverlay(point, appAvatarURL, imageUrl);
|
||||
mapRef.current.addOverlay(avatarMarker);
|
||||
};
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
addMarker(DEFAULT_POSITION.lng, DEFAULT_POSITION.lat);
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
position => addMarker(position.coords.longitude, position.coords.latitude),
|
||||
() => {
|
||||
addMarker(DEFAULT_POSITION.lng, DEFAULT_POSITION.lat);
|
||||
toaster.danger(gettext('Failed to get user location'));
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderBaiduMap = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
if (!window.BMap.Map) return;
|
||||
let mapCenter = window.sfMetadataContext.localStorage.getItem('map-center') || DEFAULT_POSITION;
|
||||
// ask for user location
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition((userInfo) => {
|
||||
mapCenter = { lng: userInfo.coords.longitude, lat: userInfo.coords.latitude };
|
||||
window.sfMetadataContext.localStorage.setItem('map-center', mapCenter);
|
||||
});
|
||||
}
|
||||
if (!isValidPosition(mapCenter?.lng, mapCenter?.lat)) return;
|
||||
|
||||
const gcPosition = wgs84_to_gcj02(mapCenter.lng, mapCenter.lat);
|
||||
const bdPosition = gcj02_to_bd09(gcPosition.lng, gcPosition.lat);
|
||||
const { lng, lat } = bdPosition;
|
||||
|
||||
mapRef.current = new window.BMap.Map('sf-metadata-map-container', { enableMapClick: false });
|
||||
const point = new window.BMap.Point(lng, lat);
|
||||
mapRef.current.centerAndZoom(point, DEFAULT_ZOOM);
|
||||
mapRef.current.enableScrollWheelZoom(true);
|
||||
|
||||
addMapController();
|
||||
initializeUserMarker();
|
||||
initializeClusterer();
|
||||
|
||||
batchIndexRef.current = 0; // Reset batch index
|
||||
renderMarkersBatch();
|
||||
}, [addMapController, initializeClusterer, initializeUserMarker, renderMarkersBatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapInfo.type === MAP_TYPE.B_MAP) {
|
||||
window.renderMap = renderBaiduMap;
|
||||
loadBMap(mapInfo.key).then(() => renderBaiduMap());
|
||||
return () => {
|
||||
window.renderMap = null;
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [mapInfo, renderBaiduMap]);
|
||||
|
||||
return (
|
||||
<div className="w-100 h-100 sf-metadata-view-map">
|
||||
{isLoading ? (
|
||||
<CenteredLoading />
|
||||
) : (
|
||||
<div className="sf-metadata-map-container" ref={mapRef} id="sf-metadata-map-container"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Map;
|
@ -1,4 +1,5 @@
|
||||
import { MAP_TYPE } from '../constants';
|
||||
import { mediaUrl } from './constants';
|
||||
|
||||
export const initMapInfo = ({ baiduMapKey, googleMapKey, mineMapKey }) => {
|
||||
if (baiduMapKey) return { type: MAP_TYPE.B_MAP, key: baiduMapKey };
|
||||
@ -25,3 +26,41 @@ export const loadMapSource = (type, key, callback) => {
|
||||
}
|
||||
callback && callback();
|
||||
};
|
||||
|
||||
export default function loadBMap(ak) {
|
||||
return new Promise((resolve, reject) => {
|
||||
asyncLoadBaiduJs(ak)
|
||||
.then(() => asyncLoadJs(`${mediaUrl}/js/map/text-icon-overlay.js`))
|
||||
.then(() => asyncLoadJs(`${mediaUrl}/js/map/marker-clusterer.js`))
|
||||
.then(() => resolve(true))
|
||||
.catch((err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
export function asyncLoadBaiduJs(ak) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof window.BMap !== 'undefined') {
|
||||
resolve(window.BMap);
|
||||
return;
|
||||
}
|
||||
window.renderMap = function () {
|
||||
resolve(window.BMap);
|
||||
};
|
||||
let script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.src = `https://api.map.baidu.com/api?v=3.0&ak=${ak}&callback=renderMap`;
|
||||
script.onerror = reject;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export function asyncLoadJs(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.src = url;
|
||||
document.body.appendChild(script);
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
BIN
media/favicons/map.png
Normal file
BIN
media/favicons/map.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
12
media/img/current-location.svg
Normal file
12
media/img/current-location.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#212529;}
|
||||
</style>
|
||||
<path class="st0" d="M16,1c8.3,0,15,6.7,15,15s-6.7,15-15,15S1,24.3,1,16S7.7,1,16,1z M17.5,4.7v3c0,0.8-0.7,1.5-1.5,1.5
|
||||
s-1.5-0.7-1.5-1.5v-3c-5,0.7-9,4.7-9.7,9.7h3c0.8,0,1.5,0.7,1.5,1.5c0,0.8-0.7,1.5-1.5,1.5h-3c0.7,5.1,4.7,9.1,9.7,9.8v-3.1
|
||||
c0-0.8,0.7-1.5,1.5-1.5c0.8,0,1.5,0.7,1.5,1.5v3.1c5.1-0.7,9.1-4.7,9.8-9.8h-3.1c-0.8,0-1.5-0.7-1.5-1.5s0.7-1.5,1.5-1.5h3.1
|
||||
C26.6,9.4,22.5,5.4,17.5,4.7z M16,12c2.2,0,4,1.8,4,4s-1.8,4-4,4s-4-1.8-4-4S13.8,12,16,12z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 861 B |
BIN
media/img/marker.png
Normal file
BIN
media/img/marker.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
617
media/js/map/marker-clusterer.js
Normal file
617
media/js/map/marker-clusterer.js
Normal file
@ -0,0 +1,617 @@
|
||||
/**
|
||||
* @fileoverview MarkerClusterer标记聚合器用来解决加载大量点要素到地图上产生覆盖现象的问题,并提高性能。
|
||||
* 主入口类是<a href="symbols/BMapLib.MarkerClusterer.html">MarkerClusterer</a>,
|
||||
* 基于Baidu Map API 1.2。
|
||||
*
|
||||
* @author Baidu Map Api Group
|
||||
* @version 1.2
|
||||
*/
|
||||
|
||||
/**
|
||||
* @namespace BMap的所有library类均放在BMapLib命名空间下
|
||||
*/
|
||||
var BMapLib = window.BMapLib = BMapLib || {};
|
||||
(function(){
|
||||
|
||||
/**
|
||||
* 获取一个扩展的视图范围,把上下左右都扩大一样的像素值。
|
||||
* @param {Map} map BMap.Map的实例化对象
|
||||
* @param {BMap.Bounds} bounds BMap.Bounds的实例化对象
|
||||
* @param {Number} gridSize 要扩大的像素值
|
||||
*
|
||||
* @return {BMap.Bounds} 返回扩大后的视图范围。
|
||||
*/
|
||||
var getExtendedBounds = function(map, bounds, gridSize){
|
||||
bounds = cutBoundsInRange(bounds);
|
||||
var pixelNE = map.pointToPixel(bounds.getNorthEast());
|
||||
var pixelSW = map.pointToPixel(bounds.getSouthWest());
|
||||
pixelNE.x += gridSize;
|
||||
pixelNE.y -= gridSize;
|
||||
pixelSW.x -= gridSize;
|
||||
pixelSW.y += gridSize;
|
||||
var newNE = map.pixelToPoint(pixelNE);
|
||||
var newSW = map.pixelToPoint(pixelSW);
|
||||
return new BMap.Bounds(newSW, newNE);
|
||||
};
|
||||
|
||||
/**
|
||||
* 按照百度地图支持的世界范围对bounds进行边界处理
|
||||
* @param {BMap.Bounds} bounds BMap.Bounds的实例化对象
|
||||
*
|
||||
* @return {BMap.Bounds} 返回不越界的视图范围
|
||||
*/
|
||||
var cutBoundsInRange = function (bounds) {
|
||||
var maxX = getRange(bounds.getNorthEast().lng, -180, 180);
|
||||
var minX = getRange(bounds.getSouthWest().lng, -180, 180);
|
||||
var maxY = getRange(bounds.getNorthEast().lat, -74, 74);
|
||||
var minY = getRange(bounds.getSouthWest().lat, -74, 74);
|
||||
return new BMap.Bounds(new BMap.Point(minX, minY), new BMap.Point(maxX, maxY));
|
||||
};
|
||||
|
||||
/**
|
||||
* 对单个值进行边界处理。
|
||||
* @param {Number} i 要处理的数值
|
||||
* @param {Number} min 下边界值
|
||||
* @param {Number} max 上边界值
|
||||
*
|
||||
* @return {Number} 返回不越界的数值
|
||||
*/
|
||||
var getRange = function (i, mix, max) {
|
||||
mix && (i = Math.max(i, mix));
|
||||
max && (i = Math.min(i, max));
|
||||
return i;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断给定的对象是否为数组
|
||||
* @param {Object} source 要测试的对象
|
||||
*
|
||||
* @return {Boolean} 如果是数组返回true,否则返回false
|
||||
*/
|
||||
var isArray = function (source) {
|
||||
return '[object Array]' === Object.prototype.toString.call(source);
|
||||
};
|
||||
|
||||
/**
|
||||
* 返回item在source中的索引位置
|
||||
* @param {Object} item 要测试的对象
|
||||
* @param {Array} source 数组
|
||||
*
|
||||
* @return {Number} 如果在数组内,返回索引,否则返回-1
|
||||
*/
|
||||
var indexOf = function(item, source){
|
||||
var index = -1;
|
||||
if(isArray(source)){
|
||||
if (source.indexOf) {
|
||||
index = source.indexOf(item);
|
||||
} else {
|
||||
for (var i = 0, m; m = source[i]; i++) {
|
||||
if (m === item) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
/**
|
||||
*@exports MarkerClusterer as BMapLib.MarkerClusterer
|
||||
*/
|
||||
var MarkerClusterer =
|
||||
/**
|
||||
* MarkerClusterer
|
||||
* @class 用来解决加载大量点要素到地图上产生覆盖现象的问题,并提高性能
|
||||
* @constructor
|
||||
* @param {Map} map 地图的一个实例。
|
||||
* @param {Json Object} options 可选参数,可选项包括:<br />
|
||||
* markers {Array<Marker>} 要聚合的标记数组<br />
|
||||
* girdSize {Number} 聚合计算时网格的像素大小,默认60<br />
|
||||
* maxZoom {Number} 最大的聚合级别,大于该级别就不进行相应的聚合<br />
|
||||
* minClusterSize {Number} 最小的聚合数量,小于该数量的不能成为一个聚合,默认为2<br />
|
||||
* isAverangeCenter {Boolean} 聚合点的落脚位置是否是所有聚合在内点的平均值,默认为否,落脚在聚合内的第一个点<br />
|
||||
* styles {Array<IconStyle>} 自定义聚合后的图标风格,请参考TextIconOverlay类<br />
|
||||
*/
|
||||
BMapLib.MarkerClusterer = function(map, options){
|
||||
if (!map){
|
||||
return;
|
||||
}
|
||||
this._map = map;
|
||||
this._markers = [];
|
||||
this._clusters = [];
|
||||
|
||||
var opts = options || {};
|
||||
this._gridSize = opts["gridSize"] || 60;
|
||||
this._maxZoom = opts["maxZoom"] || 18;
|
||||
this._minClusterSize = opts["minClusterSize"] || 2;
|
||||
this._isAverageCenter = false;
|
||||
if (opts['isAverageCenter'] != undefined) {
|
||||
this._isAverageCenter = opts['isAverageCenter'];
|
||||
}
|
||||
this._styles = opts["styles"] || [];
|
||||
|
||||
var that = this;
|
||||
this._map.addEventListener("zoomend",function(){
|
||||
that._redraw();
|
||||
});
|
||||
|
||||
// this._map.addEventListener("moveend",function(){
|
||||
// that._redraw();
|
||||
// });
|
||||
|
||||
var mkrs = opts["markers"];
|
||||
isArray(mkrs) && this.addMarkers(mkrs);
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加要聚合的标记数组。
|
||||
* @param {Array<Marker>} markers 要聚合的标记数组
|
||||
*
|
||||
* @return 无返回值。
|
||||
*/
|
||||
MarkerClusterer.prototype.addMarkers = function(markers){
|
||||
for(var i = 0, len = markers.length; i <len ; i++){
|
||||
this._pushMarkerTo(markers[i]);
|
||||
}
|
||||
this._createClusters();
|
||||
};
|
||||
|
||||
/**
|
||||
* 把一个标记添加到要聚合的标记数组中
|
||||
* @param {BMap.Marker} marker 要添加的标记
|
||||
*
|
||||
* @return 无返回值。
|
||||
*/
|
||||
MarkerClusterer.prototype._pushMarkerTo = function(marker){
|
||||
var index = indexOf(marker, this._markers);
|
||||
if(index === -1){
|
||||
marker.isInCluster = false;
|
||||
this._markers.push(marker);//Marker拖放后enableDragging不做变化,忽略
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加一个聚合的标记。
|
||||
* @param {BMap.Marker} marker 要聚合的单个标记。
|
||||
* @return 无返回值。
|
||||
*/
|
||||
MarkerClusterer.prototype.addMarker = function(marker) {
|
||||
this._pushMarkerTo(marker);
|
||||
this._createClusters();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据所给定的标记,创建聚合点,并且遍历所有聚合点
|
||||
* @return 无返回值
|
||||
*/
|
||||
MarkerClusterer.prototype._createClusters = function(){
|
||||
var mapBounds = this._map.getBounds();
|
||||
var extendedBounds = getExtendedBounds(this._map, mapBounds, this._gridSize);
|
||||
for(var i = 0, marker; marker = this._markers[i]; i++){
|
||||
if(!marker.isInCluster && extendedBounds.containsPoint(marker.getPosition()) ){
|
||||
this._addToClosestCluster(marker);
|
||||
}
|
||||
}
|
||||
|
||||
var len = this._markers.length;
|
||||
for (var i = 0; i < len; i++) {
|
||||
if(this._clusters[i]){
|
||||
this._clusters[i].render();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据标记的位置,把它添加到最近的聚合中
|
||||
* @param {BMap.Marker} marker 要进行聚合的单个标记
|
||||
*
|
||||
* @return 无返回值。
|
||||
*/
|
||||
MarkerClusterer.prototype._addToClosestCluster = function (marker){
|
||||
var distance = 4000000;
|
||||
var clusterToAddTo = null;
|
||||
var position = marker.getPosition();
|
||||
for(var i = 0, cluster; cluster = this._clusters[i]; i++){
|
||||
var center = cluster.getCenter();
|
||||
if(center){
|
||||
var d = this._map.getDistance(center, marker.getPosition());
|
||||
if(d < distance){
|
||||
distance = d;
|
||||
clusterToAddTo = cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clusterToAddTo && clusterToAddTo.isMarkerInClusterBounds(marker)){
|
||||
clusterToAddTo.addMarker(marker);
|
||||
} else {
|
||||
var cluster = new Cluster(this);
|
||||
cluster.addMarker(marker);
|
||||
this._clusters.push(cluster);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除上一次的聚合的结果
|
||||
* @return 无返回值。
|
||||
*/
|
||||
MarkerClusterer.prototype._clearLastClusters = function(){
|
||||
for(var i = 0, cluster; cluster = this._clusters[i]; i++){
|
||||
cluster.remove();
|
||||
}
|
||||
this._clusters = [];//置空Cluster数组
|
||||
this._removeMarkersFromCluster();//把Marker的cluster标记设为false
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除某个聚合中的所有标记
|
||||
* @return 无返回值
|
||||
*/
|
||||
MarkerClusterer.prototype._removeMarkersFromCluster = function(){
|
||||
for(var i = 0, marker; marker = this._markers[i]; i++){
|
||||
marker.isInCluster = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 把所有的标记从地图上清除
|
||||
* @return 无返回值
|
||||
*/
|
||||
MarkerClusterer.prototype._removeMarkersFromMap = function(){
|
||||
for(var i = 0, marker; marker = this._markers[i]; i++){
|
||||
marker.isInCluster = false;
|
||||
this._map.removeOverlay(marker);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除单个标记
|
||||
* @param {BMap.Marker} marker 需要被删除的marker
|
||||
*
|
||||
* @return {Boolean} 删除成功返回true,否则返回false
|
||||
*/
|
||||
MarkerClusterer.prototype._removeMarker = function(marker) {
|
||||
var index = indexOf(marker, this._markers);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
this._map.removeOverlay(marker);
|
||||
this._markers.splice(index, 1);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除单个标记
|
||||
* @param {BMap.Marker} marker 需要被删除的marker
|
||||
*
|
||||
* @return {Boolean} 删除成功返回true,否则返回false
|
||||
*/
|
||||
MarkerClusterer.prototype.removeMarker = function(marker) {
|
||||
var success = this._removeMarker(marker);
|
||||
if (success) {
|
||||
this._clearLastClusters();
|
||||
this._createClusters();
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除一组标记
|
||||
* @param {Array<BMap.Marker>} markers 需要被删除的marker数组
|
||||
*
|
||||
* @return {Boolean} 删除成功返回true,否则返回false
|
||||
*/
|
||||
MarkerClusterer.prototype.removeMarkers = function(markers) {
|
||||
var success = false;
|
||||
for (var i = 0; i < markers.length; i++) {
|
||||
var r = this._removeMarker(markers[i]);
|
||||
success = success || r;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
this._clearLastClusters();
|
||||
this._createClusters();
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从地图上彻底清除所有的标记
|
||||
* @return 无返回值
|
||||
*/
|
||||
MarkerClusterer.prototype.clearMarkers = function() {
|
||||
this._clearLastClusters();
|
||||
this._removeMarkersFromMap();
|
||||
this._markers = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 重新生成,比如改变了属性等
|
||||
* @return 无返回值
|
||||
*/
|
||||
MarkerClusterer.prototype._redraw = function () {
|
||||
this._clearLastClusters();
|
||||
this._createClusters();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取网格大小
|
||||
* @return {Number} 网格大小
|
||||
*/
|
||||
MarkerClusterer.prototype.getGridSize = function() {
|
||||
return this._gridSize;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置网格大小
|
||||
* @param {Number} size 网格大小
|
||||
* @return 无返回值
|
||||
*/
|
||||
MarkerClusterer.prototype.setGridSize = function(size) {
|
||||
this._gridSize = size;
|
||||
this._redraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取聚合的最大缩放级别。
|
||||
* @return {Number} 聚合的最大缩放级别。
|
||||
*/
|
||||
MarkerClusterer.prototype.getMaxZoom = function() {
|
||||
return this._maxZoom;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置聚合的最大缩放级别
|
||||
* @param {Number} maxZoom 聚合的最大缩放级别
|
||||
* @return 无返回值
|
||||
*/
|
||||
MarkerClusterer.prototype.setMaxZoom = function(maxZoom) {
|
||||
this._maxZoom = maxZoom;
|
||||
this._redraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取聚合的样式风格集合
|
||||
* @return {Array<IconStyle>} 聚合的样式风格集合
|
||||
*/
|
||||
MarkerClusterer.prototype.getStyles = function() {
|
||||
return this._styles;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置聚合的样式风格集合
|
||||
* @param {Array<IconStyle>} styles 样式风格数组
|
||||
* @return 无返回值
|
||||
*/
|
||||
MarkerClusterer.prototype.setStyles = function(styles) {
|
||||
this._styles = styles;
|
||||
this._redraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个聚合的最小数量。
|
||||
* @return {Number} 单个聚合的最小数量。
|
||||
*/
|
||||
MarkerClusterer.prototype.getMinClusterSize = function() {
|
||||
return this._minClusterSize;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置单个聚合的最小数量。
|
||||
* @param {Number} size 单个聚合的最小数量。
|
||||
* @return 无返回值。
|
||||
*/
|
||||
MarkerClusterer.prototype.setMinClusterSize = function(size) {
|
||||
this._minClusterSize = size;
|
||||
this._redraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个聚合的落脚点是否是聚合内所有标记的平均中心。
|
||||
* @return {Boolean} true或false。
|
||||
*/
|
||||
MarkerClusterer.prototype.isAverageCenter = function() {
|
||||
return this._isAverageCenter;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取聚合的Map实例。
|
||||
* @return {Map} Map的示例。
|
||||
*/
|
||||
MarkerClusterer.prototype.getMap = function() {
|
||||
return this._map;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有的标记数组。
|
||||
* @return {Array<Marker>} 标记数组。
|
||||
*/
|
||||
MarkerClusterer.prototype.getMarkers = function() {
|
||||
return this._markers;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取聚合的总数量。
|
||||
* @return {Number} 聚合的总数量。
|
||||
*/
|
||||
MarkerClusterer.prototype.getClustersCount = function() {
|
||||
var count = 0;
|
||||
for(var i = 0, cluster; cluster = this._clusters[i]; i++){
|
||||
cluster.isReal() && count++;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
* Cluster
|
||||
* @class 表示一个聚合对象,该聚合,包含有N个标记,这N个标记组成的范围,并有予以显示在Map上的TextIconOverlay等。
|
||||
* @constructor
|
||||
* @param {MarkerClusterer} markerClusterer 一个标记聚合器示例。
|
||||
*/
|
||||
function Cluster(markerClusterer){
|
||||
this._markerClusterer = markerClusterer;
|
||||
this._map = markerClusterer.getMap();
|
||||
this._minClusterSize = markerClusterer.getMinClusterSize();
|
||||
this._isAverageCenter = markerClusterer.isAverageCenter();
|
||||
this._center = null;//落脚位置
|
||||
this._markers = [];//这个Cluster中所包含的markers
|
||||
this._gridBounds = null;//以中心点为准,向四边扩大gridSize个像素的范围,也即网格范围
|
||||
this._isReal = false; //真的是个聚合
|
||||
|
||||
this._clusterMarker = new BMapLib.TextIconOverlay(this._center, this._markers.length, {"styles":this._markerClusterer.getStyles()});
|
||||
//this._map.addOverlay(this._clusterMarker);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向该聚合添加一个标记。
|
||||
* @param {Marker} marker 要添加的标记。
|
||||
* @return 无返回值。
|
||||
*/
|
||||
Cluster.prototype.addMarker = function(marker){
|
||||
if(this.isMarkerInCluster(marker)){
|
||||
return false;
|
||||
}//也可用marker.isInCluster判断,外面判断OK,这里基本不会命中
|
||||
|
||||
if (!this._center){
|
||||
this._center = marker.getPosition();
|
||||
this.updateGridBounds();//
|
||||
} else {
|
||||
if(this._isAverageCenter){
|
||||
var l = this._markers.length + 1;
|
||||
var lat = (this._center.lat * (l - 1) + marker.getPosition().lat) / l;
|
||||
var lng = (this._center.lng * (l - 1) + marker.getPosition().lng) / l;
|
||||
this._center = new BMap.Point(lng, lat);
|
||||
this.updateGridBounds();
|
||||
}//计算新的Center
|
||||
}
|
||||
|
||||
marker.isInCluster = true;
|
||||
this._markers.push(marker);
|
||||
};
|
||||
|
||||
/**
|
||||
* 进行dom操作
|
||||
* @return 无返回值
|
||||
*/
|
||||
Cluster.prototype.render = function(){
|
||||
var len = this._markers.length;
|
||||
|
||||
if (len < this._minClusterSize) {
|
||||
for (var i = 0; i < len; i++) {
|
||||
this._map.addOverlay(this._markers[i]);
|
||||
}
|
||||
} else {
|
||||
this._map.addOverlay(this._clusterMarker);
|
||||
this._isReal = true;
|
||||
this.updateClusterMarker();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个标记是否在该聚合中。
|
||||
* @param {Marker} marker 要判断的标记。
|
||||
* @return {Boolean} true或false。
|
||||
*/
|
||||
Cluster.prototype.isMarkerInCluster= function(marker){
|
||||
if (this._markers.indexOf) {
|
||||
return this._markers.indexOf(marker) != -1;
|
||||
} else {
|
||||
for (var i = 0, m; m = this._markers[i]; i++) {
|
||||
if (m === marker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断一个标记是否在该聚合网格范围中。
|
||||
* @param {Marker} marker 要判断的标记。
|
||||
* @return {Boolean} true或false。
|
||||
*/
|
||||
Cluster.prototype.isMarkerInClusterBounds = function(marker) {
|
||||
return this._gridBounds.containsPoint(marker.getPosition());
|
||||
};
|
||||
|
||||
Cluster.prototype.isReal = function(marker) {
|
||||
return this._isReal;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新该聚合的网格范围。
|
||||
* @return 无返回值。
|
||||
*/
|
||||
Cluster.prototype.updateGridBounds = function() {
|
||||
var bounds = new BMap.Bounds(this._center, this._center);
|
||||
this._gridBounds = getExtendedBounds(this._map, bounds, this._markerClusterer.getGridSize());
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新该聚合的显示样式,也即TextIconOverlay。
|
||||
* @return 无返回值。
|
||||
*/
|
||||
Cluster.prototype.updateClusterMarker = function () {
|
||||
if (this._map.getZoom() > this._markerClusterer.getMaxZoom()) {
|
||||
this._clusterMarker && this._map.removeOverlay(this._clusterMarker);
|
||||
for (var i = 0, marker; marker = this._markers[i]; i++) {
|
||||
this._map.addOverlay(marker);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._markers.length < this._minClusterSize) {
|
||||
this._clusterMarker.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this._clusterMarker.setPosition(this._center);
|
||||
|
||||
// this._clusterMarker.setText(this._markers.length);
|
||||
var imageUrl = this._markers[0].getImageUrl();
|
||||
this._clusterMarker.setText(this._markers.length, imageUrl);
|
||||
|
||||
var thatMap = this._map;
|
||||
var thatBounds = this.getBounds();
|
||||
this._clusterMarker.addEventListener("click", function(event){
|
||||
thatMap.setViewport(thatBounds);
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除该聚合。
|
||||
* @return 无返回值。
|
||||
*/
|
||||
Cluster.prototype.remove = function(){
|
||||
for (var i = 0, m; m = this._markers[i]; i++) {
|
||||
this._markers[i].getMap() && this._map.removeOverlay(this._markers[i])
|
||||
}//清除散的标记点
|
||||
this._map.removeOverlay(this._clusterMarker);
|
||||
this._markers.length = 0;
|
||||
delete this._markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取该聚合所包含的所有标记的最小外接矩形的范围。
|
||||
* @return {BMap.Bounds} 计算出的范围。
|
||||
*/
|
||||
Cluster.prototype.getBounds = function() {
|
||||
var bounds = new BMap.Bounds(this._center,this._center);
|
||||
for (var i = 0, marker; marker = this._markers[i]; i++) {
|
||||
bounds.extend(marker.getPosition());
|
||||
}
|
||||
return bounds;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取该聚合的落脚点。
|
||||
* @return {BMap.Point} 该聚合的落脚点。
|
||||
*/
|
||||
Cluster.prototype.getCenter = function() {
|
||||
return this._center;
|
||||
};
|
||||
|
||||
})();
|
1077
media/js/map/text-icon-overlay.js
Normal file
1077
media/js/map/text-icon-overlay.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user