diff --git a/src/css/stylish.css b/src/css/stylish.css index 19db7a1..9423524 100644 --- a/src/css/stylish.css +++ b/src/css/stylish.css @@ -38,10 +38,8 @@ body { grid-template-columns: 25% 25%; grid-auto-flow: column; grid-auto-columns: 50% 50%; - margin-left: 50px; } - /* Mapbox GL Stuff */ .mapboxgl-popup { @@ -212,7 +210,7 @@ body { /* Media */ -@media only screen and (max-device-width: 480px) { +@media only screen and (max-device-width: 580px) { .mapboxgl-control-container { display: none; @@ -237,6 +235,10 @@ body { transition: 0.5s; } + #date-selector-container { + grid-template-columns: unset; + } + .map-overlay .closebtn { position: absolute; display: block; diff --git a/src/js/app.jsx b/src/js/app.jsx index c59ebd8..0c34980 100644 --- a/src/js/app.jsx +++ b/src/js/app.jsx @@ -1,38 +1,56 @@ import React, { Component } from 'react'; import { render } from 'react-dom'; +import _ from 'lodash'; import $ from 'jquery'; +import mapboxgl from 'mapbox-gl'; +import 'mapbox-gl/dist/mapbox-gl.css'; import Parser from './components/Parser'; import Dates from './components/dates/Dates'; -import ShowMap from './components/Map'; + +import * as Util from './components/Util'; import '../css/stylish.css'; +mapboxgl.accessToken = 'pk.eyJ1IjoibWV0YXN5biIsImEiOiIwN2FmMDNhNTRhOWQ3NDExODI1MTllMDk1ODc3NTllZiJ9.Bye80QJ4r0RJsKj4Sre6KQ'; + +const CLUSTER_RADIUS = 50; + class Application extends Component { constructor(props) { super(props); + this.toggleCheckbox = this.toggleCheckbox.bind(this); this.state = { - dates: {}, + dates: [], }; - this.toggleCheckbox = this.toggleCheckbox.bind(this); - } - componentWillMount() { - } + this.onscreenShows = []; - toggleCheckbox(date) { - const idx = this.state.dates.findIndex((obj => obj.date === date)); - this.state.dates[idx].checked = !this.state.dates[idx].checked; + this.popup = new mapboxgl.Popup({ + closeButton: false, + }); + + this.filterEl = document.getElementById('feature-filter'); + this.listingEl = document.getElementById('feature-listing'); + + this.bindMap = this.bindMap.bind(this); + + this.start = { + lng: -122.416, + lat: 37.76, + zoom: 13, + }; } - prepare() { + + componentDidMount() { const parsed = new Parser().parseData(); // keys: organized, dates parsed.then((data) => { - // Pass dates - this.state.dates = data.dates; + // Set the selected dates + this.setState({ dates: data.dates }); // Add the dates const dateEl = document.getElementById('date-selector-container'); @@ -41,12 +59,7 @@ class Application extends Component { handleCheckboxChange={this.toggleCheckbox} />, dateEl); - // Add the shows - const mapEl = document.getElementById('app'); - render(, mapEl); + this.setupMap(data.geojson, this.state.dates); // Modals $('#filter-button').on('click', () => { @@ -70,11 +83,284 @@ class Application extends Component { }); } + // Called on any state changes + componentDidUpdate() { + if (this.map) { + // Get valid dates + const checkedDates = _.filter(this.state.dates, _.matches({ checked: true })); + const checkedDatesList = _.map(checkedDates, 'date'); + + // Filter + const filtered = this.onscreenShows.filter(feature => + _.includes(checkedDatesList, feature.properties.date)); + + // Set filter for points + this.map.setFilter('shows', ['in', 'date'].concat(checkedDatesList)); + + // Update source, for clusters + this.map.getSource('shows').setData({ + type: 'FeatureCollection', + features: filtered, + }); + + this.renderListings(this.map, filtered); + } + } + + setupMap(geojson, dates) { + const { lng, lat, zoom } = this.start; + + const map = new mapboxgl.Map({ + container: this.mapContainer, + style: 'mapbox://styles/mapbox/dark-v9', + center: [lng, lat], + zoom, + }); + + this.map = map; + + // Add locator control + map.addControl(new mapboxgl.GeolocateControl({ + positionOpionts: { + enableHighAccuracy: true, + }, + trackUserLocation: true, + })); + + // Add the actual shows + map.on('load', () => { + map.addSource('shows', { + type: 'geojson', + data: geojson, + cluster: true, + clusterMaxZoom: 14, + clusterRadius: CLUSTER_RADIUS, + }); + + // Main layer + map.addLayer({ + id: 'shows', + type: 'circle', + source: 'shows', + paint: { + 'circle-color': '#11b4da', + 'circle-radius': 10, + 'circle-stroke-width': 1, + 'circle-stroke-color': '#fff', + }, + }); + + + function inBounds(coordinates, bounds) { + // coordinates is always lngLat + // eslint-disable-next-line + const ew = _.inRange(coordinates[0], bounds._sw.lng, bounds._ne.lng); + // eslint-disable-next-line + const ns = _.inRange(coordinates[1], bounds._sw.lat, bounds._ne.lat); + return ns && ew; + } + + const getCheckedDatesList = () => { + const checkedDates = _.filter(dates, _.matches({ checked: true })); + return _.map(checkedDates, 'date'); + }; + + const showAllShows = (data) => { + map.getSource('shows').setData(data); + map.setFilter('shows', ['has', 'sid']); + }; + + const loadOnscreenShows = () => { + const bounds = map.getBounds(); + const features = geojson.features.filter(x => + inBounds(x.geometry.coordinates, bounds) && + _.includes(getCheckedDatesList(), x.properties.date)); + + if (features) { + const uniqueFeatures = Util.getUniqueFeatures(features, 'bands'); + // Populate features for the listing overlay. + this.renderListings(map, uniqueFeatures); + + // Store the current features in sn `onscreenShows` variable to + // later use for filtering on `keyup`. + this.onscreenShows = uniqueFeatures; + } + }; + + map.on('moveend', loadOnscreenShows); + // We are in 'load' listener already + loadOnscreenShows(); + + map.on('click', 'clusters', (e) => { + map.easeTo({ + center: e.features[0].geometry.coordinates, + zoom: map.getZoom() + 1, + }); + }); + + map.on('click', 'shows', e => this.addPopupAndEase(map, e)); + + this.filterEl.addEventListener('keyup', (e) => { + const value = Util.normalize(e.target.value); + // Unset filter if empty + if (value === '') { + showAllShows(geojson); + } + + if (this.popup.getLngLat()) { + this.popup.remove(); + } + + // Filter visible features that don't match the input value. + const filtered = this.onscreenShows.filter((feature) => { + const selected = _.includes(getCheckedDatesList(), feature.properties.date); + const match = (x) => { + const prop = Util.normalize(feature.properties[x]); + return prop.indexOf(value) > -1 && selected; + }; + return Object.keys(feature.properties).some(match); + }); + + + // Populate the sidebar with filtered results + this.renderListings(map, filtered); + + // Filter on source, for clusters + map.getSource('shows').setData({ + type: 'FeatureCollection', + features: filtered, + }); + + // Set the filter to populate features into the layer. + const filteredShows = filtered.map(feature => feature.properties.sid); + map.setFilter('shows', ['in', 'sid'].concat(filteredShows)); + }); + + // Call this function on initialization + // passing an empty array to render an empty state + this.renderListings(map, []); + + $('#filter-all').on('click', () => { + // Util.toggleDates('all'); + showAllShows(geojson); + this.filterEl.value = ''; + $('.close-filter-modal').click(); + }); + + map.addLayer({ + id: 'clusters', + type: 'circle', + source: 'shows', + filter: ['has', 'point_count'], + paint: { + // Use step expressions (https://www.mapbox.com/mapbox-gl-js/style-spec/#expressions-step) + // with three steps to implement three types of circles: + 'circle-color': [ + 'step', + ['get', 'point_count'], + '#51bbd6', + 50, + '#f1f075', + 100, + '#f28cb1', + ], + 'circle-radius': [ + 'step', + ['get', 'point_count'], + 20, + 50, + 30, + 100, + 40, + ], + }, + }); + + map.addLayer({ + id: 'cluster-count', + type: 'symbol', + source: 'shows', + layout: { + 'text-field': '{point_count_abbreviated}', + 'text-size': 12, + }, + }); + }); + } + + toggleCheckbox(date) { + const { dates } = this.state; + const idx = dates.findIndex((obj => obj.date === date)); + dates[idx].checked = !dates[idx].checked; + this.setState({ dates }); + } + + addPopupAndEase(map, e) { + // Change the cursor style as a UI indicator. + // eslint-disable-next-line + map.getCanvas().style.cursor = 'pointer'; + + if (this.popup.getLngLat()) { + this.popup.remove(); + } + + const point = e.features[0].geometry.coordinates; + + map.easeTo({ + center: point, + zoom: 15, + duration: 1000, + }); + + // Populate the popup and set its coordinates based on the feature. + if (!e.features[0].properties.cluster) { + Util.addPopup(map, e.features, this.popup); + } + } + + bindMap(el) { + this.mapContainer = el; + } + + renderListings(map, features) { + // Clear any existing listings + this.listingEl.innerHTML = ''; + if (features.length) { + features.forEach((feature) => { + const prop = feature.properties; + const item = document.createElement('p'); + item.textContent = prop.showString; + const venue = `

${prop.venue}


`; + item.addEventListener('mouseover', () => { + // Highlight corresponding feature on the map + this.popup.setLngLat(feature.geometry.coordinates) + .setHTML(venue + prop.showHTML) + .addTo(map); + }); + this.listingEl.appendChild(item); + }); + + // Show the filter input + this.filterEl.parentNode.style.display = 'block'; + } else { + const empty = document.createElement('p'); + const text = this.filterEl.value === '' ? 'Drag the map to populate results' : 'No shows match criteria.'; + empty.textContent = text; + this.listingEl.appendChild(empty); + + // Hide the filter input + this.filterEl.parentNode.style.display = 'block'; + + // remove features filter + map.setFilter('shows', ['has', 'sid']); + } + } render() { - this.prepare(); return ( -
+
+
+
); } } diff --git a/src/js/components/dates/DateSelector.jsx b/src/js/components/dates/DateSelector.jsx index 1f3dd56..2128c32 100644 --- a/src/js/components/dates/DateSelector.jsx +++ b/src/js/components/dates/DateSelector.jsx @@ -7,31 +7,30 @@ export default class DateSelector extends Component { this.state = { isChecked: true, }; + this.handleChange = this.handleChange.bind(this); } - toggleDateCheckboxChange = () => { - const { handleCheckboxChange, date } = this.props; - + handleChange(e) { + // Update dates in application for map to use + this.props.handleCheckboxChange(e.target.value); + // Handle local state this.setState(({ isChecked }) => ( { isChecked: !isChecked, } )); - - handleCheckboxChange(date); } render() { const { date } = this.props; - const { isChecked } = this.state; return (
{date}
); diff --git a/src/js/components/dates/Dates.jsx b/src/js/components/dates/Dates.jsx index a2f99be..55e313d 100644 --- a/src/js/components/dates/Dates.jsx +++ b/src/js/components/dates/Dates.jsx @@ -4,7 +4,12 @@ import PropTypes from 'prop-types'; import DateSelector from './DateSelector'; export default class Dates extends Component { - makeDateSelectors = (dates) => { + constructor(props) { + super(props); + this.makeDateSelectors = this.makeDateSelectors.bind(this); + } + + makeDateSelectors(dates) { const selectors = []; for (let d = 0; d < dates.length; d += 1) { const selector = ( { - map.addSource('shows', { - type: 'geojson', - data: this.props.geojson, - cluster: true, - clusterMaxZoom: 14, - clusterRadius: CLUSTER_RADIUS, - }); - - // Main layer - map.addLayer({ - id: 'shows', - type: 'circle', - source: 'shows', - paint: { - 'circle-color': '#11b4da', - 'circle-radius': 10, - 'circle-stroke-width': 1, - 'circle-stroke-color': '#fff', - }, - }); - - - function inBounds(coordinates, bounds) { - // coordinates is always lngLat - // eslint-disable-next-line - const ew = _.inRange(coordinates[0], bounds._sw.lng, bounds._ne.lng); - // eslint-disable-next-line - const ns = _.inRange(coordinates[1], bounds._sw.lat, bounds._ne.lat); - return ns && ew; - } - - const showAllShows = (data) => { - // todo - _.each(this.props.dates, x => _.set(x, 'checked', true)); - map.getSource('shows').setData(data); - map.setFilter('shows', ['has', 'sid']); - }; - - map.on('moveend', () => { - const bounds = map.getBounds(); - const checkedDates = _.filter(this.props.dates, _.matches({ checked: true })); - const checkedDatesList = _.map(checkedDates, 'date'); - const features = this.props.geojson.features.filter(x => - inBounds(x.geometry.coordinates, bounds) && _.includes(checkedDatesList, x.properties.date)); - - if (features) { - const uniqueFeatures = Util.getUniqueFeatures(features, 'bands'); - // Populate features for the listing overlay. - this.renderListings(map, uniqueFeatures); - - // Store the current features in sn `onscreenShows` variable to - // later use for filtering on `keyup`. - this.onscreenShows = uniqueFeatures; - } - }); - - - map.on('click', 'clusters', (e) => { - map.easeTo({ - center: e.features[0].geometry.coordinates, - zoom: map.getZoom() + 1, - }); - }); - - map.on('click', 'shows', e => this.addPopupAndEase(map, e)); - - this.filterEl.addEventListener('keyup', (e) => { - const value = Util.normalize(e.target.value); - // Unset filter if empty - if (value === '') { - showAllShows(this.props.geojson); - } - - if (this.popup.getLngLat()) { - this.popup.remove(); - } - - // Filter visible features that don't match the input value. - // - const checkedDates = _.filter(this.props.dates, _.matches({ checked: true })); - const checkedDatesList = _.map(checkedDates, 'date'); - const filtered = this.onscreenShows.filter((feature) => { - const selected = _.includes(checkedDatesList, feature.properties.date); - const match = (x) => { - const prop = Util.normalize(feature.properties[x]); - return prop.indexOf(value) > -1 && selected; - }; - return Object.keys(feature.properties).some(match); - }); - - - // Populate the sidebar with filtered results - this.renderListings(map, filtered); - - // Filter on source, for clusters - map.getSource('shows').setData({ - type: 'FeatureCollection', - features: filtered, - }); - - // Set the filter to populate features into the layer. - const filteredShows = filtered.map(feature => feature.properties.sid); - map.setFilter('shows', ['in', 'sid'].concat(filteredShows)); - }); - - // Call this function on initialization - // passing an empty array to render an empty state - this.renderListings(map, []); - - $('#filter-all').on('click', () => { - // Util.toggleDates('all'); - showAllShows(this.props.geojson); - this.filterEl.value = ''; - $('.close-filter-modal').click(); - }); - - map.addLayer({ - id: 'clusters', - type: 'circle', - source: 'shows', - filter: ['has', 'point_count'], - paint: { - // Use step expressions (https://www.mapbox.com/mapbox-gl-js/style-spec/#expressions-step) - // with three steps to implement three types of circles: - 'circle-color': [ - 'step', - ['get', 'point_count'], - '#51bbd6', - 50, - '#f1f075', - 100, - '#f28cb1', - ], - 'circle-radius': [ - 'step', - ['get', 'point_count'], - 20, - 50, - 30, - 100, - 40, - ], - }, - }); - - map.addLayer({ - id: 'cluster-count', - type: 'symbol', - source: 'shows', - layout: { - 'text-field': '{point_count_abbreviated}', - 'text-size': 12, - }, - }); - }); - } - - addPopupAndEase(map, e) { - // Change the cursor style as a UI indicator. - // eslint-disable-next-line - map.getCanvas().style.cursor = 'pointer'; - - if (this.popup.getLngLat()) { - this.popup.remove(); - } - - const point = e.features[0].geometry.coordinates; - - map.easeTo({ - center: point, - zoom: 15, - duration: 1000, - }); - - // Populate the popup and set its coordinates based on the feature. - if (!e.features[0].properties.cluster) { - Util.addPopup(map, e.features, this.popup); - } - } - - - bindMap(el) { - this.mapContainer = el; - } - - renderListings(map, features) { - // Clear any existing listings - this.listingEl.innerHTML = ''; - if (features.length) { - features.forEach((feature) => { - const prop = feature.properties; - const item = document.createElement('p'); - item.textContent = prop.showString; - const venue = `

${prop.venue}


`; - item.addEventListener('mouseover', () => { - // Highlight corresponding feature on the map - this.popup.setLngLat(feature.geometry.coordinates) - .setHTML(venue + prop.showHTML) - .addTo(map); - }); - this.listingEl.appendChild(item); - }); - - // Show the filter input - this.filterEl.parentNode.style.display = 'block'; - } else { - const empty = document.createElement('p'); - const text = this.filterEl.value === '' ? 'Drag the map to populate results' : 'No shows match criteria.'; - empty.textContent = text; - this.listingEl.appendChild(empty); - - // Hide the filter input - this.filterEl.parentNode.style.display = 'block'; - - // remove features filter - map.setFilter('shows', ['has', 'sid']); - } - } - - - render() { - return ( -
-
-
- ); - } -} - -ShowMap.propTypes = { - // eslint-disable-next-line - geojson: PropTypes.object.isRequired, - dates: PropTypes.array.isRequired, -}; - -export default ShowMap;