avlmap
Version:
Avail Map by Avail Labs
875 lines (760 loc) • 30.2 kB
JavaScript
/** @jsx React.DOM */
import React from "react"
import { render } from 'react-dom'
import mapboxgl from 'mapbox-gl/dist/mapbox-gl'
//import { MAPBOX_TOKEN } from 'store/config'
import deepequal from "deep-equal"
import get from "lodash.get"
import styled from "styled-components"
import { format as d3format } from "d3-format"
import Sidebar from './components/sidebar'
import Infobox from './components/infobox/Infobox'
import MapPopover from "./components/popover/MapPopover"
import MapModal from "./components/modal/MapModal"
import MapActions from "./components/MapActions"
import MapMessages from "./components/MapMessages"
import { ScalableLoading } from "./components/loading/loadingPage"
import './avlmap.css'
import {
dispatchMessage,
FilterMessage
} from "./LayerMessageSystem"
mapboxgl.accessToken = this.props.MAPBOX_TOKEN
let UNIQUE_ID = 0;
const getUniqueId = (str = "unique-id") =>
`${ str }-${ ++UNIQUE_ID }`
const getStaticImageUrl = style =>
`https://api.mapbox.com/styles/v1/am3081/${ style }/static/` +
`${ -74.2179 },${ 43.2994 },1.5/60x40?` +
`attribution=false&logo=false&access_token=${ mapboxgl.accessToken }`
export const DEFAULT_STYLES = [
{ name: "Dark",
style: "mapbox://styles/am3081/cjqqukuqs29222sqwaabcjy29" },
{ name: "Light",
style: 'mapbox://styles/am3081/cjms1pdzt10gt2skn0c6n75te' },
{ name: "Satellite",
style: 'mapbox://styles/am3081/cjya6wla3011q1ct52qjcatxg' },
{ name: "Satellite Streets",
style: `mapbox://styles/am3081/cjya70364016g1cpmbetipc8u` }
]
class AvlMap extends React.Component {
addActiveMap(id, component, map){
AvlMap.ActiveMaps[id] = { component, map };
}
removeActiveMap(id){
delete AvlMap.ActiveMaps[id];
}
updateMap([id, action, ...args]){
if (id in AvlMap.ActiveMaps) {
const { component } = AvlMap.ActiveMaps[id];
component && component[action] && component[action].call(component, ...args);
}
}
doAction([id, action, ...args]){
console.log('in do action')
if (id in AvlMap.ActiveMaps) {
const { component } = AvlMap.ActiveMaps[id];
if(component && component[action]) {
console.log('do action', id, action, ...args)
let result = component[action].call(component, ...args);
console.log('action result ', result)
return result
}
}
}
testFunc(...args) {
console.log("TEST FUNCTION:", ...[...args].map(arg => arg.toString()));
}
constructor(props) {
super(props);
this.state = {
id: this.props.id || getUniqueId('avl-map'),
map: null,
dynamicLayers: [],
activeLayers: [],
sources: {},
popover: {
pos: [0, 0],
pinned: false,
data: [],
layer: null
},
dragging: null,
dragover: null,
width: 0,
height: 0,
messages: [],
isOpen: true,
transitioning: false,
style: props.styles.reduce((a, c) =>
((c.name === props.styleName) || (c.name === props.style)) ? c : a
, props.styles[0])
}
this.MOUNTED = false;
this.container = React.createRef();
}
setState(...args) {
this.MOUNTED && super.setState(...args);
}
forceUpdate(...args) {
this.MOUNTED && super.forceUpdate(...args);
}
componentDidMount() {
this.MOUNTED = true;
const {
center,
minZoom,
zoom,
mapControl,
preserveDrawingBuffer,
style
} = this.props;
const { id } = this.state;
const regex = /^mapbox:\/\/styles\//;
const map = new mapboxgl.Map({
container: id,
style: regex.test(style) ? style : this.state.style.style,
center,
minZoom,
zoom,
attributionControl: false,
preserveDrawingBuffer
});
if(mapControl) {
map.addControl(new mapboxgl.NavigationControl(), mapControl);
}
if(!this.props.boxZoom){
map.boxZoom.disable();
}
if(!this.props.scrollZoom) {
map.scrollZoom.disable();
};
([...document.getElementsByClassName("mapboxgl-ctrl-logo")])
.forEach(logo => {
logo.parentElement.style.margin = '0';
logo.style.display = 'none';
})
this.props.layers.forEach(layer => {
layer.version = layer.version || 1.0;
layer.initComponent(this);
});
map.on('load', () => {
const activeLayers = [];
this.props.layers.forEach(layer => {
layer.initMap(map);
if (layer.active) {
this._addLayer(map, layer, activeLayers);
activeLayers.push(layer.name);
layer._onAdd(map);
++layer.loading;
const layerProps = get(this.props.layerProps, layer.name, {});
Promise.resolve(layer.onAdd(map, layerProps))
.then(() => --layer.loading)
.then(() => layer.render(map))
.then(() => this.setState({ activeLayers }));
}
})
if (this.props.fitBounds){
map.fitBounds(this.props.fitBounds)
}
this.setState({ map, activeLayers: [] })
AvlMap.addActiveMap(id, this, map);
})
// map.on('sourcedata', () => this.foceUpdate());
this.setContainerSize();
}
componentWillUnmount() {
// this.state.activeLayers.forEach(layer => this.removeLayer(layer));
this.MOUNTED = false;
AvlMap.removeActiveMap(this.state.id);
}
componentDidUpdate(oldProps, oldState) {
this.setContainerSize();
this.state.activeLayers.forEach(layerName => {
const layer = this.getLayer(layerName);
const layerProps = get(this.props, ["layerProps", layerName], null);
if (layerProps) {
layer.receiveProps(oldProps.layerProps[layerName], layerProps);
}
if (!deepequal(oldProps.layerProps[layerName], layerProps)) {
layer.onPropsChange(oldProps.layerProps[layerName], layerProps);
}
})
}
addDynamicLayer(layerName, layerFactory) {
return new Promise((resolve, reject) => {
if (!this.state.map) return resolve();
const layer = this.getLayer(layerName);
if (!layer) return resolve();
const newLayer = layerFactory.call(null, layer),
newLayerName = newLayer.name,
allLayers = [
...this.props.layers,
...this.state.dynamicLayers
];
newLayer._isDynamic = true;
newLayer.initComponent(this);
newLayer.initMap(this.state.map);
const adjustName = allLayers.reduce((a, c) =>
a || c.name === newLayerName
, false)
if (adjustName) {
const regExpStr = newLayerName + " " + "\\((\\d+)\\)",
regex = new RegExp(regExpStr),
num = allLayers.reduce((a, c) => {
const match = regex.exec(c.name);
if (match) {
return Math.max(a, +match[1]);
}
return a;
}, 1);
newLayer.name = `${newLayerName} (${num + 1})`;
}
if (newLayer.active) {
this._addLayer(this.state.map, newLayer);
++newLayer.loading;
newLayer._onAdd(this.state.map);
const layerProps = get(this.props.layerProps, newLayer.name, {});
Promise.resolve(newLayer.onAdd(this.state.map, layerProps))
.then(() => --newLayer.loading)
.then(() => newLayer.render(this.state.map))
.then(() => this.setState({activeLayers: [...this.state.activeLayers, newLayer.name]}));
}
this.setState({
dynamicLayers: [
...this.state.dynamicLayers,
newLayer
]
})
resolve(newLayer)
})
}
deleteDynamicLayer(layerName, otherLayerName=false) {
layerName = otherLayerName || layerName;
const layer = this.getLayer(layerName);
if (!layer) return;
this.removeLayer(layerName);
this.setState({
dynamicLayers: this.state.dynamicLayers.filter(l => l.name !== layerName)
})
}
sendMessage(layerName, data) {
data = {
id: getUniqueId(),
duration: data.onConfirm ? 0 : 6000,
...data,
Message: data.msg || data.message || data.Message,
update: false,
layer: this.getLayer(layerName)
}
// console.log("<AvlMap.sendMessage>", layerName, data, [...this.state.messages]);
const update = this.state.messages.reduce((a, c) => a || (c.id === data.id), false);
let messages = [...this.state.messages];
if (update) {
messages = messages.map(({ id, Message, ...rest }) => ({
Message: id === data.id ? data.Message : Message,
id,
...rest,
update: id === data.id ? Date.now() : false
}))
}
else {
messages = [...messages, data];
}
this.setState({ messages });
}
dismissMessage(id) {
const messages = this.state.messages.filter(m => m.id !== id);
// console.log("<AvlMap.dismissMessage>", id, messages);
this.setState({ messages });
}
renderLayer(layerName) {
const layer = this.getLayer(layerName);
layer && layer.active && layer.render(this.state.map);
}
setContainerSize() {
const div = this.container.current,
width = div.scrollWidth,
height = div.scrollHeight;
if ((width !== this.state.width) || (height !== this.state.height)) {
this.setState({ width, height })
}
}
getLayer(layerName) {
return [
...this.props.layers,
...this.state.dynamicLayers
]
.reduce((a, c) => c.name === layerName ? c : a, null);
}
_addLayer(map, newLayer, activeLayers=this.state.activeLayers) {
const sources = { ...this.state.sources };
const sourcesToAdd = new Set(newLayer.layers.map(l => l.source))
newLayer.sources.forEach(source => {
if (!sourcesToAdd.has(source.id)) return;
if (!map.getSource(source.id)) {
map.addSource(source.id, source.source);
}
if (!(source.id in sources)) {
sources[source.id] = [];
}
})
const activeMBLayers = activeLayers.reduce((a, ln) => {
const layer = this.props.layers.reduce((a, c) => c.name === ln ? c : a);
return [...a, ...layer.layers];
}, [])
const newMBLayers = newLayer.layers.slice();
newMBLayers.sort((a, b) => {
const azi = a.zIndex || 0,
bzi = b.zIndex || 0;
return azi - bzi;
})
//console.log('mbLayers', newMBLayers)
newMBLayers.forEach(mbLayer => {
const zIndex = mbLayer.zIndex || 0;
let layerAdded = false;
activeMBLayers.forEach(aMBL => {
const aMBLzIndex = aMBL.zIndex || 0;
if (aMBLzIndex > zIndex) {
if(!map.getLayer(mbLayer.id)) {
map.addLayer(mbLayer, aMBL.id);
layerAdded = true;
}
}
})
if (!layerAdded) {
if (Boolean(mbLayer.beneath) && Boolean(map.getLayer(mbLayer.beneath))) {
map.addLayer(mbLayer, mbLayer.beneath);
}
else {
map.addLayer(mbLayer);
}
}
sources[mbLayer.source].push(mbLayer.id)
})
this.setState({ sources });
}
addLayer(layerName, otherLayerName=false) {
layerName = otherLayerName || layerName;
const layer = this.getLayer(layerName);
if (this.state.map && layer && !layer.active) {
layer.active = true;
this._addLayer(this.state.map, layer);
++layer.loading;
layer._onAdd(this.state.map);
const layerProps = get(this.props.layerProps, layerName, {});
Promise.resolve(layer.onAdd(this.state.map, layerProps))
.then(() => --layer.loading)
.then(() => layer.render(this.state.map))
.then(() => this.setState({ activeLayers: [...this.state.activeLayers, layerName] }));
;
}
}
removeLayer(layerName, otherLayerName=false) {
layerName = otherLayerName || layerName;
const layer = this.getLayer(layerName);
if (this.state.map && layer && layer.active && !layer.loading) {
layer.active = false;
layer._onRemove(this.state.map);
layer.onRemove(this.state.map);
const sourcesToRemove = []
layer.layers.forEach(layer => {
this.state.map.removeLayer(layer.id);
sourcesToRemove.push([layer.source, layer.id])
});
const sources = { ...this.state.sources };
sourcesToRemove.forEach(([sourceId, layerId]) => {
if (sourceId in sources) {
sources[sourceId] = sources[sourceId].filter(lId => lId !== layerId);
}
})
layer.sources.forEach(source => {
if (get(sources, [source.id, "length"], "not-added") === 0) {
this.state.map.removeSource(source.id);
delete sources[source.id]
}
})
this.setState({ activeLayers: this.state.activeLayers.filter(ln => ln !== layerName), sources });
}
else if (this.state.map && layer && layer.active && layer.loading) {
this.sendMessage(null, { Message: "Cannot remove a layer while it is loading." })
}
}
toggleLayerVisibility(layerName) {
const layer = this.getLayer(layerName);
if (this.state.map && layer) {
layer.toggleVisibility(this.state.map);
this.forceUpdate();
}
}
updatePopover(layerName, update) {
if ((update.pinned === false) && this.state.popover.pinned) {
const func = this.state.popover.layer.popover.onUnPinned;
(typeof func === "function") && func.call(this.state.popover.layer);
this.state.popover.layer._clearPinnedState();
}
this.setState({ popover: { ...this.state.popover, ...update }});
}
toggleModal(layerName, modalName, props={}) {
const layer = this.getLayer(layerName),
modal = layer.modals[modalName],
show = !modal.show;
this.props.layers.forEach(layer => {
if (layer.modals) {
for (const modal in layer.modals) {
layer.modals[modal].show = false;
}
}
})
modal.show = show;
modal.props = modal.props ? { ...modal.props, ...props } : props;
if (!show && (typeof modal.onClose === "function")) {
modal.onClose.call(layer);
}
this.forceUpdate();
}
updateModal(layerName, modalName, props={}) {
const layer = this.getLayer(layerName),
modal = layer.modals[modalName];
modal.props = modal.props ? { ...modal.props, ...props } : props;
this.forceUpdate();
}
onSelect(layerName, selection) {
if (!this.state.map) return;
const layer = this.getLayer(layerName)
layer.selection = selection;
++layer.loading;
this.forceUpdate();
layer.onSelect(selection)
// .then(() => layer.fetchData())
.then(data => layer.active && (layer.receiveDataOld(this.state.map, data), layer.render(this.state.map)))
.then(() => --layer.loading)
.then(() => this.forceUpdate());
}
toggleInfoBox(layerName, infoBoxName) {
const layer = this.getLayer(layerName)
if (layer.infoBoxes) {
const infoBox = layer.infoBoxes[infoBoxName];
if (infoBox) {
infoBox.show = !infoBox.show;
}
}
this.forceUpdate();
}
updateFilter(layerName, filterName, value = null) {
if (!this.state.map) return;
const layer = this.getLayer(layerName),
filter = layer.filters[filterName],
oldValue = filter.value,
domain = filter.domain;
(value !== null) && (filter.value = value);
let onChange = () => {};
if (layer.filters[filterName].onChange) {
if (layer.version >= 2) {
onChange = () => layer.filters[filterName].onChange.call(layer, oldValue, value, domain);
}
else {
onChange = () => layer.filters[filterName].onChange(this.state.map, layer, value, oldValue);
}
}
++layer.loading;
this.forceUpdate();
// Promise.resolve(onChange())
// .then(() => layer.onFilterFetch(filterName, oldValue, value))
layer.onFilterFetch(filterName, oldValue, value)
.then(data => {
if (layer.active) {
onChange();
layer.receiveDataOld(this.state.map, data);
(data !== false) && layer.render(this.state.map);
}
})
.then(() => --layer.loading)
.then(() => {
if (filter.dispatchMessage) {
const data = filter.dispatchFunc ? filter.dispatchFunc.call(layer) : null;
dispatchMessage(layerName, new FilterMessage(layerName, filterName, oldValue, value, data));
}
})
.then(() => this.forceUpdate());
// if (layer.filters[filterName].refLayers) {
// layer.filters[filterName].refLayers.forEach(refLayerName => {
//
// const layer = this.getLayer(refLayerName),
// filter = layer.filters[filterName],
// oldValue = filter.value,
// domain = filter.domain;
//
// (value !== null) && (filter.value = value);
//
// if (layer.active) {
//
// if (layer.filters[filterName].onChange) {
// if (layer.version >= 2) {
// layer.filters[filterName].onChange.call(layer, oldValue, value, domain);
// }
// else {
// layer.filters[filterName].onChange(this.state.map, layer, value, oldValue);
// }
// }
//
// ++layer.loading;
// this.forceUpdate();
//
// layer.onFilterFetch(filterName, oldValue, value)
// .then(data => layer.active && (layer.receiveDataOld(this.state.map, data), layer.render(this.state.map)))
// .then(() => --layer.loading)
// .then(() => this.forceUpdate());
// }
// })
// }
}
updateLegend(layerName, update) {
if (!this.state.map) return;
const layer = this.getLayer(layerName);
layer.legend = {
...layer.legend,
...update
};
++layer.loading;
this.forceUpdate();
layer.onLegendChange()
.then(data => layer.active && (layer.receiveDataOld(this.state.map, data), layer.render(this.state.map)))
.then(() => --layer.loading)
.then(() => this.forceUpdate());
}
fetchLayerData(layerName) {
if (!this.state.map) return;
const layer = this.getLayer(layerName);
++layer.loading;
this.forceUpdate();
layer.fetchData()
.then(data => layer.active && (layer.receiveDataOld(this.state.map, data), layer.render(this.state.map)))
.then(() => --layer.loading)
.then(() => this.forceUpdate());
}
updateDrag(update) {
this.setState({
...this.state,
...update
})
}
dropLayer() {
const activeLayers = this.state.activeLayers.filter(l => l !== this.state.dragging),
insertBefore = activeLayers[this.state.dragover];
activeLayers.splice(this.state.dragover, 0, this.state.dragging)
const draggingLayer = this.getLayer(this.state.dragging),
beforeLayer = this.getLayer(insertBefore);
let beforeLayerId = null;
if (beforeLayer) {
beforeLayerId = beforeLayer.layers[0].id;
}
draggingLayer.layers.forEach(({ id }) => {
this.state.map.moveLayer(id, beforeLayerId)
})
this.setState({ activeLayers });
const layersWithZIndex = activeLayers.reduce((a, c) => {
const layer = this.getLayer(c),
mbLayers = layer.layers.reduce((a, c) => {
return c.zIndex ? [...a, c] : a;
}, []);
return [...a, ...mbLayers];
}, [])
layersWithZIndex.sort((a, b) => a.zIndex - b.zIndex);
layersWithZIndex.forEach(mbLayer => {
this.state.map.moveLayer(mbLayer.id);
})
}
onTransitionStart() {
this.setState({ transitioning: true });
}
onOpenOrClose(isOpen) {
this.setState({ isOpen, transitioning: false });
}
setMapStyle(style) {
const { map } = this.state;
if (Boolean(map) && (style.style !== this.state.style.style)) {
map.once('style.load', e => {
const activeLayers = [];
this.state.activeLayers.forEach(layerName => {
const layer = this.getLayer(layerName);
this._addLayer(map, layer, activeLayers);
activeLayers.push(layerName);
layer.onStyleChange(map);
});
})
this.state.activeLayers.forEach(layerName => {
const layer = this.getLayer(layerName);
layer._onRemove(map);
})
map.setStyle(style.style);
}
this.setState({ style });
}
render() {
const actionMap = {
toggleModal: this.toggleModal.bind(this),
updateModal: this.updateModal.bind(this),
toggleInfoBox: this.toggleInfoBox.bind(this)
}
const allLayers = [
...this.props.layers,
...this.state.dynamicLayers
]
const mapStyles = this.props.styles.map(s => ({
...s,
url: getStaticImageUrl(s.style.slice(23))
}))
return (
<div key={this.state.id} style={{ height:this.props.height}} ref={this.container}>
{ !this.props.sidebar ? null :
<Sidebar isOpen={ this.state.isOpen }
transitioning={ this.state.transitioning }
onOpenOrClose={ this.onOpenOrClose.bind(this) }
onTransitionStart={ this.onTransitionStart.bind(this) }
layers={ allLayers }
activeLayers={ this.state.activeLayers }
addLayer={ this.addLayer.bind(this) }
removeLayer={ this.removeLayer.bind(this) }
deleteDynamicLayer={ this.deleteDynamicLayer.bind(this) }
toggleLayerVisibility={ this.toggleLayerVisibility.bind(this) }
actionMap= { actionMap }
header={ this.props.header }
toggleModal={ this.toggleModal.bind(this) }
updateModal={ this.updateModal.bind(this) }
updateFilter={ this.updateFilter.bind(this) }
updateLegend={ this.updateLegend.bind(this) }
fetchLayerData={ this.fetchLayerData.bind(this) }
updateDrag={ this.updateDrag.bind(this) }
dropLayer={ this.dropLayer.bind(this) }
pages={ this.props.sidebarPages }
mapStyles={ mapStyles }
style={ this.state.style }
setMapStyle={ this.setMapStyle.bind(this) }
map={ this.state.map }/>
}
<Infobox layers={ allLayers }
activeLayers={ this.state.activeLayers }/>
<MapPopover { ...this.state.popover }
updatePopover={ this.updatePopover.bind(this) }
mapSize={ {
width: this.state.width,
height: this.state.height
} }/>
<MapModal layers={ allLayers }
activeLayers={ this.state.activeLayers }
toggleModal={ this.toggleModal.bind(this) }/>
{ !this.props.mapactions ? null :
<MapActions layers={ allLayers }
activeLayers={ this.state.activeLayers }
sidebar={ this.props.sidebar }
isOpen={ (this.state.isOpen && !this.state.transitioning) || (!this.state.isOpen && this.state.transitioning) }
actionMap={ actionMap }/>
}
<MapMessages
messages={ this.state.messages }
dismiss={ this.dismissMessage.bind(this) }/>
<LoadingLayers layers={ allLayers }
sidebar={ this.props.sidebar }
isOpen={ (this.state.isOpen && !this.state.transitioning) || (!this.state.isOpen && this.state.transitioning) }/>
</div>
)
}
}
AvlMap.defaultProps = {
id: null,
height: "100%",
styles: [...DEFAULT_STYLES],
styleName: "Dark",
style: null,
center: [-73.680647, 42.68],
minZoom: 2,
zoom: 10,
layers: [],
mapControl: 'bottom-right',
scrollZoom: true,
boxZoom: true,
sidebar: true,
mapactions: true,
update: [],
header: "AVAIL Map",
sidebarPages: ["layers", "basemaps"],
layerProps: {},
preserveDrawingBuffer: false,
MAPBOX_TOKEN : ''
}
AvlMap.ActiveMaps = {};
const LoadingContainer = styled.div`
position: absolute;
bottom: 20px;
left: ${ props => props.sidebar && props.isOpen ? 340 : props.sidebar && !props.isOpen ? 40 : 20 }px;
transition: left 0.25s;
z-index: 50;
display: flex;
flex-direction: column;
pointer-events: none;
color: ${ props => props.theme.textColorHl };
> * {
margin-bottom: 10px;
min-width: 300px;
background-color: ${ props => props.theme.sidePanelBg };
border-radius: 4px;
border-top-right-radius: ${ props => (props.height + props.padding * 2) * 0.5 }px;
border-bottom-right-radius: ${ props => (props.height + props.padding * 2) * 0.5 }px;
font-size: 1rem;
}
> *:last-child {
margin-bottom: 0px;
}
`
class LoadingIndicator extends React.Component {
constructor(props) {
super(props);
this.state = {
progress: null
}
}
componentDidMount() {
this.props.layer.registerLoadingIndicator(this.setState.bind(this));
}
componentWillUnmount() {
this.props.layer.unregisterLoadingIndicator();
}
render() {
const { layer } = this.props,
height = 40,
padding = 10;
return (
<div key={ layer.name } style={ { height: `${ height + 20 }px`, padding: `${ padding }px`, display: "flex" } }>
<div style={ { height: `${ height }px`, lineHeight: `${ height }px`, textAlign: "left", width: `65%` } }>
{ layer.name }
</div>
<div style={ { paddingLeft: `${ padding }px`, height: `${ height }px`, lineHeight: `${ height }px`, textAlign: "left", width: `calc(35% - ${ height }px)` } }>
{ this.state.progress === null ? null :
`${ this.format(this.state.progress) }`
}
</div>
<ScalableLoading scale={ height * 0.01 }/>
</div>
)
}
}
LoadingIndicator.format = d3format(".0%");
class LoadingLayers extends React.Component {
render() {
const { layers, sidebar, isOpen } = this.props,
loadingLayers = layers.reduce((a, c) => {
c.loading && a.push(c);
// a.push(c);
return a;
}, []),
height = 40,
padding = 10;
return (
<LoadingContainer sidebar={ sidebar } isOpen={ isOpen } height={ height } padding={ padding }>
{ loadingLayers.map((l, i) => <LoadingIndicator key={ l.name } layer={ l }/>) }
</LoadingContainer>
)
}
}
export default AvlMap