mirror of https://github.com/perkeep/perkeep.git
474 lines
14 KiB
JavaScript
474 lines
14 KiB
JavaScript
/*
|
|
Copyright 2017 The Perkeep Authors
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
goog.provide('cam.MapAspect');
|
|
|
|
goog.require('cam.SearchSession');
|
|
goog.require('cam.Thumber');
|
|
|
|
// freeze/unfreeze cluster plugin, strongly inspired from
|
|
// https://github.com/ghybs/Leaflet.MarkerCluster.Freezable
|
|
L.MarkerClusterGroup.include({
|
|
unfreeze: function () {
|
|
this._processQueue();
|
|
if (!this._map) {
|
|
return this;
|
|
}
|
|
this._unfreeze();
|
|
return this;
|
|
},
|
|
|
|
freeze: function () {
|
|
this._processQueue();
|
|
if (!this._map) {
|
|
return this;
|
|
}
|
|
this._initiateFreeze();
|
|
return this;
|
|
},
|
|
|
|
_initiateFreeze: function () {
|
|
var map = this._map;
|
|
|
|
// Start freezing
|
|
this._frozen = true;
|
|
|
|
if (map) {
|
|
// Change behaviour on zoomEnd and moveEnd.
|
|
map.off('zoomend', this._zoomEnd, this);
|
|
map.off('moveend', this._moveEnd, this);
|
|
}
|
|
},
|
|
|
|
_unfreeze: function () {
|
|
var map = this._map;
|
|
|
|
this._frozen = false;
|
|
|
|
if (map) {
|
|
// Restore original behaviour on zoomEnd.
|
|
map.on('zoomend', this._zoomEnd, this);
|
|
map.on('moveend', this._moveEnd, this);
|
|
|
|
if (this._unspiderfy && this._spiderfied) {
|
|
this._unspiderfy();
|
|
}
|
|
this._zoomEnd();
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
cam.MapAspect = React.createClass({
|
|
// QUERY_LIMIT_ is the maximum number of location markers to draw. It is not
|
|
// arbitrary, as higher numbers (such as 1000) seem to be causing glitches.
|
|
// (https://github.com/perkeep/perkeep/issues/937)
|
|
// However, the cluster plugin restricts the number of items displayed at the
|
|
// same time to a way lower number, allowing us to work-around these glitches.
|
|
QUERY_LIMIT_: 250,
|
|
// ZOOM_COOLDOWN_ is how much time to wait, after we've stopped zooming/panning,
|
|
// before actually searching for new results.
|
|
ZOOM_COOLDOWN_: 500,
|
|
|
|
propTypes: {
|
|
availWidth: React.PropTypes.number.isRequired,
|
|
availHeight: React.PropTypes.number.isRequired,
|
|
searchSession: React.PropTypes.instanceOf(cam.SearchSession).isRequired,
|
|
config: React.PropTypes.object.isRequired,
|
|
updateSearchBar: React.PropTypes.func.isRequired,
|
|
setPendingQuery: React.PropTypes.func.isRequired,
|
|
},
|
|
|
|
componentWillMount: function() {
|
|
this.clusteringOn = this.props.config.mapClustering;
|
|
if (this.clusteringOn == false) {
|
|
// Even 100 is actually too much, and https://github.com/perkeep/perkeep/issues/937 ensues
|
|
this.QUERY_LIMIT_ = 100;
|
|
}
|
|
// isMoving, in conjunction with ZOOM_COOLDOWN_, allows to actually ask for
|
|
// new results only once we've stopped zooming/panning.
|
|
this.isMoving = false;
|
|
this.firstLoad = true;
|
|
this.markers = {};
|
|
if (this.cluster) {
|
|
this.cluster.clearLayers();
|
|
} else if (this.markersGroup) {
|
|
this.markersGroup.clearLayers();
|
|
}
|
|
this.cluster = null;
|
|
this.markersGroup = null;
|
|
this.mapQuery = null;
|
|
this.locationFromMarkers = null;
|
|
this.initialSearchSession = this.props.searchSession;
|
|
},
|
|
|
|
componentWillReceiveProps: function(nextProps) {
|
|
if (this.props == nextProps) {
|
|
// first load. componentWillMount takes care of the init.
|
|
return;
|
|
}
|
|
if (this.props.searchSession == nextProps.searchSession) {
|
|
// search session has not changed, nothing to do.
|
|
return;
|
|
}
|
|
// Everything below is how we reload from (almost) scratch when a new search is
|
|
// entered in the search box.
|
|
this.componentWillMount();
|
|
this.initialSearchSession = nextProps.searchSession;
|
|
this.loadMarkers();
|
|
},
|
|
|
|
componentDidMount: function() {
|
|
this.eh_ = new goog.events.EventHandler(this);
|
|
var map = this.map = L.map(ReactDOM.findDOMNode(this), {
|
|
layers: [
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
})
|
|
],
|
|
attributionControl: true,
|
|
noWrap: true,
|
|
});
|
|
map.setView([0., 0.], 1);
|
|
|
|
this.eh_.listen(window, 'resize', function(event) {
|
|
// Even after setting the bounds, or the view center+zoom, something is still
|
|
// very wrong, and the map's bounds seem to stay a point (instead of a rectangle).
|
|
// And I can't figure out why. However, any kind of resizing of the window fixes
|
|
// things, so we send a resize event when we're done with loading the markers,
|
|
// and we do one final refreshView here after the resize has happened.
|
|
setTimeout(function(){this.refreshMapView();}.bind(this), 1000);
|
|
});
|
|
map.on('click', this.onMapClick);
|
|
map.on('zoomend', this.onZoom);
|
|
map.on('moveend', this.onZoom);
|
|
this.loadMarkers();
|
|
},
|
|
|
|
componentWillUnmount: function() {
|
|
this.map.off('click', this.onMapClick);
|
|
this.eh_.dispose();
|
|
this.map = null;
|
|
},
|
|
|
|
render: function() {
|
|
return React.DOM.div(
|
|
{
|
|
className: 'map',
|
|
style: {
|
|
// we need a low zIndex so that the main piggy scroll menu stays on top of
|
|
// the map when it unfolds.
|
|
zIndex: -1,
|
|
// because of lowest zIndex, this is apparently needed so the map still gets
|
|
// click events. css is dark magic.
|
|
position: 'absolute',
|
|
width: this.props.availWidth,
|
|
height: this.props.availHeight,
|
|
},
|
|
}
|
|
);
|
|
},
|
|
|
|
triggerInitialZoom: function() {
|
|
var q = this.initialSearchSession.getQueryExprOrRef();
|
|
q = goreact.ShiftMapZoom(q);
|
|
window.dispatchEvent(new Event('resize'));
|
|
},
|
|
|
|
// refreshMapView pans to englobe all the markers that were drawn.
|
|
refreshMapView: function() {
|
|
if (!this.locationFromMarkers) {
|
|
return;
|
|
}
|
|
this.map.fitBounds(this.locationFromMarkers);
|
|
},
|
|
|
|
// loadMarkers sets markers on the map for all the permanodes, with a location,
|
|
// found in the current search session.
|
|
loadMarkers: function() {
|
|
var ss = this.initialSearchSession;
|
|
if (!ss) {
|
|
return;
|
|
}
|
|
var q = ss.getQueryExprOrRef();
|
|
if (q == '') {
|
|
q = 'has:location';
|
|
}
|
|
if (this.mapQuery == null) {
|
|
this.mapQuery = goreact.NewMapQuery(this.props.config.authToken, q, this.handleSearchResults,
|
|
function(){
|
|
this.props.setPendingQuery(false);
|
|
}.bind(this));
|
|
if (this.mapQuery == null) {
|
|
return;
|
|
}
|
|
this.mapQuery.SetLimit(this.QUERY_LIMIT_);
|
|
}
|
|
this.props.setPendingQuery(true);
|
|
this.mapQuery.Send();
|
|
},
|
|
|
|
// TODO(mpl): if we add caching of the results to the gopherjs searchsession,
|
|
// then getMeta, getResolvedMeta, and getTitle can become methods on the Session
|
|
// type, and we can remove the searchResults argument.
|
|
|
|
getMeta: function(br, searchResults) {
|
|
if (!searchResults || !searchResults.description || !searchResults.description.meta) {
|
|
return null;
|
|
}
|
|
return searchResults.description.meta[br];
|
|
},
|
|
|
|
getResolvedMeta: function(br, searchResults) {
|
|
var meta = this.getMeta(br, searchResults);
|
|
if (!meta) {
|
|
return null;
|
|
}
|
|
if (meta.camliType == 'permanode') {
|
|
var camliContent = cam.permanodeUtils.getSingleAttr(meta.permanode, 'camliContent');
|
|
if (camliContent) {
|
|
return searchResults.description.meta[camliContent];
|
|
}
|
|
}
|
|
return meta;
|
|
},
|
|
|
|
getTitle: function(br, searchResults) {
|
|
var meta = this.getMeta(br, searchResults);
|
|
if (!meta) {
|
|
return '';
|
|
}
|
|
if (meta.camliType == 'permanode') {
|
|
var title = cam.permanodeUtils.getSingleAttr(meta.permanode, 'title');
|
|
if (title) {
|
|
return title;
|
|
}
|
|
}
|
|
var rm = this.getResolvedMeta(br, searchResults);
|
|
return (rm && rm.camliType == 'file' && rm.file.fileName) || (rm && rm.camliType == 'directory' && rm.dir.fileName) || '';
|
|
},
|
|
|
|
handleSearchResults: function(searchResultsJSON) {
|
|
var searchResults = JSON.parse(searchResultsJSON);
|
|
var blobs = searchResults.blobs;
|
|
if (blobs == null) {
|
|
blobs = [];
|
|
}
|
|
// TODO(mpl): instead of all the ifs everywhere, we could just keep on using the
|
|
// cluster as a layer group, but completely disable clustering and spiderifying.
|
|
if (this.clusteringOn) {
|
|
if (this.cluster == null) {
|
|
this.cluster = L.markerClusterGroup({
|
|
// because we handle ourselves below what the visible markers are.
|
|
removeOutsideVisibleBounds: false,
|
|
animate: false,
|
|
});
|
|
}
|
|
this.cluster.addTo(this.map);
|
|
var toAdd = [];
|
|
this.cluster.unfreeze();
|
|
} else {
|
|
if (this.markersGroup == null) {
|
|
this.markersGroup = L.layerGroup();
|
|
this.markersGroup.addTo(this.map);
|
|
}
|
|
var toAdd = L.layerGroup();
|
|
}
|
|
var toKeep = {};
|
|
blobs.forEach(function(b) {
|
|
var br = b.blob;
|
|
var alreadyMarked = this.markers[br]
|
|
if (alreadyMarked && alreadyMarked != null) {
|
|
// marker was already added in the previous zoom level, so do not readd it.
|
|
toKeep[br] = true;
|
|
return;
|
|
}
|
|
var m = this.getResolvedMeta(br, searchResults);
|
|
if (!m || !m.location) {
|
|
var pm = this.getMeta(br, searchResults);
|
|
if (!pm || !pm.location) {
|
|
return;
|
|
}
|
|
// permanode itself has a location (not its contents)
|
|
var location = pm.location;
|
|
} else {
|
|
// contents, camliPath, etc has a location
|
|
var location = m.location;
|
|
}
|
|
|
|
// all awesome markers use markers-soft.png (body of the marker), and markers-shadow.png.
|
|
var iconOpts = {
|
|
prefix: 'fa',
|
|
iconColor: 'white',
|
|
markerColor: 'blue'
|
|
};
|
|
// TODO(mpl): twitter, when we handle location for tweets, which I thought we already did.
|
|
if (m.permanode && cam.permanodeUtils.getCamliNodeType(m.permanode) == 'foursquare.com:checkin') {
|
|
iconOpts.icon = 'foursquare';
|
|
} else if (m.image) {
|
|
// image file
|
|
iconOpts.icon = 'camera';
|
|
} else if (m.camliType == 'file') {
|
|
// generic file
|
|
iconOpts.icon = 'file';
|
|
} else {
|
|
// default node
|
|
// TODO(mpl): I used 'circle' because it looks the most like the default leaflet
|
|
// marker-icon.png, but it'd be cool to have something that reminds of the
|
|
// Camlistore "brand". Maybe the head of the eagle on the banner?
|
|
iconOpts.icon = 'circle';
|
|
}
|
|
var markerIcon = L.AwesomeMarkers.icon(iconOpts);
|
|
var marker = L.marker([location.latitude, location.longitude], {icon: markerIcon});
|
|
|
|
if (m.image) {
|
|
// TODO(mpl): Do we ever want another thumb size? on mobile maybe?
|
|
var img = cam.Thumber.fromImageMeta(m).getSrc(64);
|
|
marker.bindPopup('<a href="'+this.props.config.uiRoot+br+'"><img src="'+img+'" alt="'+br+'" height="64"></a>');
|
|
} else {
|
|
var title = this.getTitle(br, searchResults);
|
|
if (title != '') {
|
|
marker.bindPopup('<a href="'+this.props.config.uiRoot+br+'">'+title+'</a>');
|
|
} else {
|
|
marker.bindPopup('<a href="'+this.props.config.uiRoot+br+'">'+br+'</a>');
|
|
}
|
|
}
|
|
toKeep[br] = true;
|
|
this.markers[br] = marker;
|
|
if (this.clusteringOn) {
|
|
toAdd.push(marker);
|
|
} else {
|
|
toAdd.addLayer(marker);
|
|
}
|
|
|
|
if (!this.locationFromMarkers) {
|
|
// initialize it as a square of 0.1 degree around the first marker placed
|
|
var northeast = L.latLng(location.latitude + 0.05, location.longitude + 0.05);
|
|
var southwest = L.latLng(location.latitude - 0.05, location.longitude - 0.05);
|
|
this.locationFromMarkers = L.latLngBounds(northeast, southwest);
|
|
} else {
|
|
// then grow locationFromMarkers to englobe the new marker (if needed)
|
|
this.locationFromMarkers.extend(L.latLng(location.latitude, location.longitude));
|
|
}
|
|
}.bind(this));
|
|
if (this.clusteringOn) {
|
|
var toRemove = [];
|
|
} else {
|
|
var toRemove = L.layerGroup();
|
|
}
|
|
goog.object.forEach(this.markers, function(mark, br) {
|
|
if (mark == null) {
|
|
return;
|
|
}
|
|
if (!toKeep[br]) {
|
|
this.markers[br] = null;
|
|
if (this.clusteringOn) {
|
|
toRemove.push(mark);
|
|
} else {
|
|
toRemove.addLayer(mark);
|
|
}
|
|
}
|
|
}.bind(this));
|
|
if (this.clusteringOn) {
|
|
this.cluster.removeLayers(toRemove);
|
|
this.cluster.addLayers(toAdd);
|
|
this.cluster.freeze();
|
|
} else {
|
|
this.markersGroup.removeLayer(toRemove);
|
|
this.markersGroup.addLayer(toAdd);
|
|
}
|
|
|
|
// TODO(mpl): reintroduce the Around/Continue logic later if needed. For now not
|
|
// needed/useless as MapSorted queries do not support continuation of any kind.
|
|
|
|
if (this.firstLoad) {
|
|
this.triggerInitialZoom();
|
|
}
|
|
// even if we're not here because of a zoom change (i.e. either first load, or
|
|
// new search was entered), we still call updateSearchBar here to update the zoom
|
|
// predicate right shift to the search bar.
|
|
this.props.updateSearchBar(this.mapQuery.GetExpr());
|
|
},
|
|
|
|
onMapClick: function() {
|
|
this.refreshMapView();
|
|
},
|
|
|
|
onZoom: function() {
|
|
if (!this.mapQuery) {
|
|
return;
|
|
}
|
|
if (this.firstLoad) {
|
|
// we are most likely right after the first load, and this is not an intentional
|
|
// pan/zoom, but rather an "automatic" pan/zoom to the first batch of results.
|
|
this.firstLoad = false;
|
|
return;
|
|
}
|
|
if (this.isMoving) {
|
|
clearTimeout(this.zoomTimeout);
|
|
}
|
|
this.isMoving = true;
|
|
this.zoomTimeout = setTimeout(this.onZoomEnd, this.ZOOM_COOLDOWN_);
|
|
},
|
|
|
|
onZoomEnd: function() {
|
|
this.isMoving = false;
|
|
if (!this.map) {
|
|
// TODO(mpl): why the hell can this happen?
|
|
return;
|
|
}
|
|
if (!this.mapQuery) {
|
|
return;
|
|
}
|
|
var newBounds = this.map.getBounds();
|
|
this.mapQuery.SetZoom(newBounds.getNorth(), newBounds.getWest(), newBounds.getSouth(), newBounds.getEast());
|
|
this.loadMarkers();
|
|
}
|
|
});
|
|
|
|
cam.MapAspect.getAspect = function(config, availWidth, availHeight, updateSearchBar, setPendingQuery,
|
|
childSearchSession, targetBlobRef, parentSearchSession) {
|
|
var searchSession = childSearchSession;
|
|
if (targetBlobRef) {
|
|
// we have a "ref:sha1-foobar" kind of query
|
|
var m = parentSearchSession.getMeta(targetBlobRef);
|
|
if (!m || !m.permanode) {
|
|
return null;
|
|
}
|
|
|
|
if (!cam.permanodeUtils.isContainer(m.permanode)) {
|
|
// sha1-foobar is not a container, so we're interested in its own properties,
|
|
// not its children's.
|
|
searchSession = parentSearchSession;
|
|
}
|
|
}
|
|
|
|
return {
|
|
fragment: 'map',
|
|
title: 'Map',
|
|
createContent: function(size) {
|
|
return React.createElement(cam.MapAspect, {
|
|
config: config,
|
|
availWidth: availWidth,
|
|
availHeight: availHeight,
|
|
searchSession: searchSession,
|
|
updateSearchBar: updateSearchBar,
|
|
setPendingQuery: setPendingQuery,
|
|
});
|
|
},
|
|
};
|
|
};
|