Files
ukraine-timemap/src/js/map/map.js
2018-12-18 17:35:07 +00:00

464 lines
13 KiB
JavaScript

import {
areEqual,
isNotNullNorUndefined
} from '../utilities';
import hash from 'object-hash';
import 'leaflet-polylinedecorator';
export default function(newApp, ui, methods) {
let svg, g, defs;
const domain = {
locations: [],
narratives: [],
categories: [],
sites: []
}
const app = {
selected: [],
highlighted: null,
narrative: null,
views: Object.assign({}, newApp.views),
}
const getCategoryColor = methods.getCategoryColor;
const narrativeProps = ui.narratives;
// Map Settings
const center = newApp.mapAnchor;
const maxBoundaries = [[180, -180], [-180, 180]];
const zoomLevel = 14;
// Initialize layer
const sitesLayer = L.layerGroup();
const pathLayer = L.layerGroup();
// Icons for markPoint flags (a yellow ring around a location)
const eventCircleMarkers = {};
// Styles for elements in map
const settingsSiteLabel = {
className: 'site-label',
opacity: 1,
permanent: true,
direction: 'top',
};
/**
* Creates a Leaflet map and a tilelayer for the map background
* @param {string} id: DOM element to create map onto
* @param {array} center: [lat, long] coordinates the map will be centered on
* @param {number} zoom: zoom level
*/
function initBackgroundMap(id, zoom) {
/* http://bl.ocks.org/sumbera/10463358 */
const map = L.map(id)
.setView(center, zoom)
.setMinZoom(10)
.setMaxZoom(18)
.setMaxBounds(maxBoundaries)
// NB: configure tile endpoint
let s
if (process.env.MAPBOX_TOKEN && process.env.MAPBOX_TOKEN !== 'your_token') {
s = L.tileLayer(
`http://a.tiles.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.png?access_token=${process.env.MAPBOX_TOKEN}`
);
} else {
// eslint-disable-next-line
alert(`No mapbox token specified in config.
Timemap does not currently support any other tiling layer,
so you will need to sign up for one at:
https://www.mapbox.com/
Stop and start the development process in terminal after you have added your token to config.js`)
return
}
s = s.addTo(map);
map.keyboard.disable();
const pane = d3.select(map.getPanes().overlayPane);
const boundingClient = d3.select(`#${id}`).node().getBoundingClientRect();
const width = boundingClient.width;
const height = boundingClient.height;
svg = pane.append('svg')
.attr('class', 'leaflet-svg')
.attr('width', width)
.attr('height', height);
g = svg.append('g');
svg.insert('defs', 'g')
.append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 0 6 6')
.attr('refX', 3)
.attr('refY', 3)
.attr('markerWidth', 14)
.attr('markerHeight', 14)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,3v-3l6,3l-6,3z');
map.on('zoomstart', () => {
svg.classed('hide', true);
});
map.on('zoomend', () => {
svg.classed('hide', false);
});
return map;
}
// Initialize leaflet map and layers for each type of data
const lMap = initBackgroundMap(ui.dom.map, zoomLevel);
function projectPoint(location) {
const latLng = new L.LatLng(location[0], location[1]);
return {
x: lMap.latLngToLayerPoint(latLng).x + getSVGBoundaries().transformX,
y: lMap.latLngToLayerPoint(latLng).y + getSVGBoundaries().transformY
};
}
function getSVGBoundaries() {
const mapNode = d3.select('.leaflet-map-pane').node();
// We'll get the transform of the leaflet container,
// which will let us offset the SVG by the same quantity
const transform = window
.getComputedStyle(mapNode)
.getPropertyValue('transform');
// However getComputedStyle returns an awkward string of the format
// matrix(0, 0, 1, 0, 0.56523, 123123), hence this awkwardness
return {
transformX: +transform.split(',')[4],
transformY: +transform.split(',')[5].split(')')[0]
}
}
function updateSVG() {
const boundingClient = d3.select(`#${ui.dom.map}`).node().getBoundingClientRect();
let WIDTH = boundingClient.width;
let HEIGHT = boundingClient.height;
// Offset with leaflet map transform boundaries
const { transformX, transformY } = getSVGBoundaries();
svg.attr('width', WIDTH)
.attr('height', HEIGHT)
.attr('style', `left: ${-transformX}px; top: ${-transformY}px`);
g.selectAll('.location').attr('transform', (d) => {
const newPoint = projectPoint([+d.latitude, +d.longitude]);
return `translate(${newPoint.x},${newPoint.y})`;
});
const sequenceLine = d3.line()
.x(d => getCoords(d).x + transformX)
.y(d => getCoords(d).y + transformY);
g.selectAll('.narrative')
.attr('d', sequenceLine);
}
lMap.on("zoomend viewreset moveend", updateSVG);
/**
* Returns latitud / longitude
* @param {Object} eventPoint: data for an evenPoint - time, loc, tags, etc
*/
function getEventLocation(eventPoint) {
return {
latitude: +eventPoint.location.latitude,
longitude: +eventPoint.location.longitude,
};
}
/*
* INTERACTIVE FUNCTIONS
*/
/**
* Removes the circular ring to mark a particular location
*/
function unmarkPoint() {
Object.keys(eventCircleMarkers).forEach(markerId => {
lMap.removeLayer(eventCircleMarkers[markerId]);
delete eventCircleMarkers[markerId];
});
}
/**
* Makes a circular ring mark in one particular location at a time
* @param {object} location object, with lat and long
*/
function renderSelected() {
unmarkPoint();
app.selected.forEach(eventPoint => {
if (isNotNullNorUndefined(eventPoint) && isNotNullNorUndefined(eventPoint.location)) {
if (eventPoint.latitude && eventPoint.latitude !== "" && eventPoint.longitude && eventPoint.longitude !== "") {
const location = new L.LatLng(eventPoint.latitude, eventPoint.longitude);
eventCircleMarkers[eventPoint.id] = L.circleMarker(location, {
radius: 32,
fill: false,
color: '#ffffff',
weight: 3,
lineCap: '',
dashArray: '5,2'
});
eventCircleMarkers[eventPoint.id].addTo(lMap);
}
}
})
}
function renderHighlighted() {
// Fly to first of events selected
const eventPoint = (app.selected.length > 0) ? app.selected[0] : null;
if (isNotNullNorUndefined(eventPoint) && isNotNullNorUndefined(eventPoint.location)) {
if (eventPoint.latitude && eventPoint.longitude) {
const location = new L.LatLng(eventPoint.latitude, eventPoint.longitude);
lMap.flyTo(location);
}
}
}
/*
* RENDERING FUNCTIONS
*/
function getLocationEventsDistribution(location) {
const eventCount = {};
const categories = domain.categories;
// categories.sort((a, b) => {
// return (+a.slice(-2) > +b.slice(-2));
// });
categories.forEach(cat => {
eventCount[cat.category] = 0
});
location.events.forEach((event) => {;
eventCount[event.category] += 1;
});
let i = 0;
const events = [];
while (i < categories.length) {
let _eventsCount = eventCount[categories[i].category];
for (let j = i + 1; j < categories.length; j++) {
_eventsCount += eventCount[categories[j].category];
}
events.push(_eventsCount);
i++;
}
return events;
}
/**
* Clears existing event layer
* Renders all events as markers
* Adds eventlayer to map
*/
function renderEvents() {
const locationsDom = g.selectAll('.location')
.data(domain.locations, d => d.id)
locationsDom
.exit()
.remove();
locationsDom
.enter().append('g')
.attr('class', 'location')
.attr('transform', (d) => {
const newPoint = projectPoint([+d.latitude, +d.longitude]);
return `translate(${newPoint.x},${newPoint.y})`;
})
.on('click', (location) => {
methods.onSelect(location.events);
});
const eventsDom = g.selectAll('.location')
.selectAll('.location-event-marker')
.data((d, i) => getLocationEventsDistribution(domain.locations[i]))
eventsDom
.exit()
.attr('r', 0)
.remove();
eventsDom
.transition()
.duration(500)
.attr('r', d => (d) ? Math.sqrt(16 * d) + 3 : 0);
eventsDom
.enter().append('circle')
.attr('class', 'location-event-marker')
.style('fill', (d, i) => getCategoryColor(domain.categories[i].category))
.transition()
.duration(500)
.attr('r', d => (d) ? Math.sqrt(16 * d) + 3 : 0);
}
// NB: is this a function to be removed for future features?
function renderSites() {
sitesLayer.clearLayers();
lMap.removeLayer(sitesLayer);
// Create a label for each attack site, persistent across filtering
if (app.views.sites) {
domain.sites.forEach((site) => {
if (isNotNullNorUndefined(site)) {
// Create an invisible marker for each site label
const siteMarker = L.circleMarker([+site.latitude, +site.longitude], {
radius: 0,
stroke: 0
});
siteMarker.bindTooltip(site.site, settingsSiteLabel).openTooltip();
// Add this one attack marker to group attack layer
sitesLayer.addLayer(siteMarker);
}
});
lMap.addLayer(sitesLayer);
}
}
const getCoords = (d) => {
d.LatLng = new L.LatLng(+d.latitude, +d.longitude);
return {
x: lMap.latLngToLayerPoint(d.LatLng).x,
y: lMap.latLngToLayerPoint(d.LatLng).y
}
}
/*const sequenceLine = d3.line()
.x(d => getCoords(d).x)
.y(d => getCoords(d).y)*/
const sequenceLine = d3.line()
.x(d => getCoords(d).x + getSVGBoundaries().transformX)
.y(d => getCoords(d).y + getSVGBoundaries().transformY);
/**
* Clears existing narrative layer
* Renders all narrativ as paths
* Adds eventlayer to map
*/
function getNarrativeStyle(narrativeId) {
const styleName = narrativeId && narrativeId in narrativeProps
? narrativeId
: 'default';
return narrativeProps[styleName];
}
function renderNarratives() {
const narrativesDom = g.selectAll('.narrative')
.data(domain.narratives.map(d => d.steps))
narrativesDom
.exit()
.remove();
narrativesDom
.enter().append('path')
.attr('class', 'narrative')
.attr('d', sequenceLine)
.style('stroke-width', d => {
if (!d[0]) return 0;
// Note: [0] is a non-elegant way to get the narrative id out of the first
// event in the narrative sequence
const styleProps = getNarrativeStyle(d[0].narrative);
return styleProps.strokeWidth;
})
.style('stroke-dasharray', d => {
if (!d[0]) return 'none';
const styleProps = getNarrativeStyle(d[0].narrative);
return (styleProps.style === 'dotted') ? "2px 5px" : 'none';
})
.style('stroke', d => {
if (!d[0]) return 'none';
const styleProps = getNarrativeStyle(d[0].narrative);
return styleProps.stroke;
})
.style('stroke-opacity', d => {
if (app.narrative === null) return 0;
if (!d[0] || !d[0].narratives.find(n => n === app.narrative.id)) return 0.2;
return 1;
})
.style('fill', 'none');
narrativesDom
.style('stroke', d => {
if (!d[0]) return 'none';
const styleProps = getNarrativeStyle(d[0].narrative);
return styleProps.stroke;
})
.style('stroke-opacity', d => {
if (app.narrative === null) return 0;
if (!d[0] || !d[0].narratives.find(n => n === app.narrative.id)) return 0.2;
return 1;
})
}
/**
* Updates displayable data on the map: events, coevents and paths
* @param {Object} domain: object of arrays of events, coevs, attacks, paths, sites
*/
function update(newDomain, newApp) {
updateSVG();
const isNewDomain = (hash(domain) !== hash(newDomain));
const isNewAppProps = (hash(app) !== hash(newApp));
if (isNewDomain) {
domain.locations = newDomain.locations;
domain.narratives = newDomain.narratives;
domain.categories = newDomain.categories;
domain.sites = newDomain.sites;
}
if (isNewAppProps) {
app.views = newApp.views;
app.selected = newApp.selected;
app.highlighted = newApp.highlighted;
app.mapAnchor = newApp.mapAnchor;
app.narrative = newApp.narrative;
}
if (isNewDomain || isNewAppProps) renderDomain();
if (isNewAppProps) renderSelectedAndHighlight();
}
/**
* Renders events on the map: takes data, and enters, updates and exits
*/
function renderDomain () {
renderSites();
renderNarratives();
renderEvents();
}
function renderSelectedAndHighlight () {
renderSelected();
renderHighlighted();
}
/**
* Expose only relevant functions
*/
return {
update
};
}