Clean master commit

This commit is contained in:
Franc FC
2018-10-31 14:11:03 -04:00
parent 59aa005a64
commit 92e03fdb07
69 changed files with 12939 additions and 0 deletions

3
.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
build/
node_modules/
config.js

17
config.example.js Normal file
View File

@@ -0,0 +1,17 @@
module.exports = {
title: 'EXAMPLE_TITLE',
SERVER_ROOT: 'http://localhost:4040',
EVENT_EXT: '/api/<ORIGIN_NAME>/MAP2D_dev/rows',
CATEGORY_EXT: '/api/<ORIGIN_NAME>/MAP2D_dev_category/rows',
EVENT_DESC_ROOT: '/api/<ORIGIN_NAME>/MAP2D_dev/ids',
TAG_TREE_EXT: '/api/<ORIGIN_NAME>/MAP2D_dev_tags/tree',
SITES_EXT: '/api/<ORIGIN_NAME>/MAP2D_dev_sites/rows',
MAP_ANCHOR: [27.5813121, -18.5161798],
INCOMING_DATETIME_FORMAT: '%m/%d/%YT%H:%M',
MAPBOX_TOKEN: 'SOME_MAPBOX_TOKEN',
features: {
USE_TAGS: false,
USE_SEARCH: false,
USE_SITES: false
}
}

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>TimeMap - Forensic Architecture</title>
<link rel="stylesheet" href="https://api.mapbox.com/mapbox.js/v3.1.1/mapbox.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js" charset="utf-8"></script>
</head>
<body>
<div class="page">
<div class="page">
<div id="explore-app"></div>
</div>
</div>
</body>
</html>

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "guerrero",
"version": "0.1.0",
"description": "",
"homepage": "",
"private": true,
"scripts": {
"dev": "webpack-dev-server --content-base static --mode development",
"build": "NODE_ENV=production webpack --mode production"
},
"dependencies": {
"babel-polyfill": "^6.26.0",
"d3": "^4.9.1",
"es6-promise": "^4.1.1",
"joi": "^14.0.1",
"leaflet": "^1.0.3",
"leaflet-polylinedecorator": "^1.3.2",
"normalizr": "^3.2.3",
"object-hash": "^1.3.0",
"react": "^15.5.4",
"react-dom": "^15.5.4",
"react-redux": "^5.0.4",
"react-tabs": "^1.0.0",
"redux": "^3.6.0",
"redux-thunk": "^2.2.0",
"reselect": "^3.0.1",
"uuid": "^3.1.0",
"video.js": "^5.19.2",
"whatwg-fetch": "^2.0.3"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.0",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.4.4",
"node-sass": "^4.9.4",
"redux-devtools": "^3.4.0",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.9"
}
}

242
src/actions/index.js Normal file
View File

@@ -0,0 +1,242 @@
// TODO: relegate these URLs entirely to environment variables
const EVENT_DATA_URL = `${process.env.SERVER_ROOT}${process.env.EVENT_EXT}`;
const CATEGORY_URL = `${process.env.SERVER_ROOT}${process.env.CATEGORY_EXT}`;
const TAG_TREE_URL = `${process.env.SERVER_ROOT}${process.env.TAG_TREE_EXT}`;
const SITES_URL = `${process.env.SERVER_ROOT}${process.env.SITES_EXT}`;
const eventUrlMap = (event) => `${process.env.SERVER_ROOT}${process.env.EVENT_DESC_ROOT}/${(event.id) ? event.id : event}`
/*
* Create an error notification object
* Types: ['error', 'warning', 'good', 'neural']
*/
function makeError(type, id, message) {
return {
type: 'error',
id,
message: `${type} ${id}: ${message}`
}
}
export function fetchDomain() {
let events = [];
let categories = [];
let sites = [];
let notifications = [];
let tags = {};
function makeError(domainType) {
notifications.push({
message: `Something went wrong fetching ${domainType}. Check the URL or try disabling them in the config file.`,
type: 'error'
});
}
return dispatch => {
dispatch(toggleFetchingDomain());
const promises = [];
const eventPromise = fetch(EVENT_DATA_URL)
.then(response => response.json())
.then(jsonEv => { events = jsonEv; })
.catch(err => { makeError('events')});
promises.push(eventPromise);
const catPromise = fetch(CATEGORY_URL)
.then(response => response.json())
.then(jsonCat => { categories = jsonCat; })
.catch(err => { makeError('categories')});
promises.push(catPromise);
if (process.env.features.USE_SITES) {
const sitesPromise = fetch(SITES_URL)
.then(response => response.json())
.then(jsonSites => { sites = jsonSites; })
.catch(err => { makeError('sites')});
promises.push(sitesPromise);
}
if (process.env.features.USE_TAGS) {
const tagTreePromise = fetch(TAG_TREE_URL)
.then(response => response.json())
.then(jsonTagTree => { tags = jsonTagTree; })
.catch(err => { makeError('tags')});
promises.push(tagTreePromise);
}
return Promise.all(promises)
.then(reponse => {
dispatch(toggleFetchingDomain());
return { events, categories, sites, tags, notifications };
})
.catch(err => {
dispatch(fetchError(err.message))
dispatch(toggleFetchingDomain());
})
};
}
export const FETCH_ERROR = 'FETCH_ERROR';
export function fetchError(message) {
return {
type: FETCH_ERROR,
message,
}
}
export const UPDATE_DOMAIN = 'UPDATE_DOMAIN';
export function updateDomain(domain) {
return {
type: UPDATE_DOMAIN,
domain: {
events: domain.events,
categories: domain.categories,
tags: domain.tags,
sites: domain.sites,
notifications: domain.notifications
}
};
}
export function fetchEvents(events) {
return dispatch => {
dispatch(toggleFetchingEvents());
const urls = events.map(eventUrlMap);
return Promise.all(urls.map(url => fetch(url)
.then(response => response.json())
)
)
.then(json => {
dispatch(toggleFetchingEvents());
return json;
});
};
}
export const UPDATE_HIGHLIGHTED = 'UPDATE_HIGHLIGHTED';
export function updateHighlighted(highlighted) {
return {
type: UPDATE_HIGHLIGHTED,
highlighted: highlighted
};
}
export const UPDATE_SELECTED = 'UPDATE_SELECTED';
export function updateSelected(selected) {
return {
type: UPDATE_SELECTED,
selected: selected
};
}
export const UPDATE_DISTRICT = 'UPDATE_DISTRICT';
export function updateDistrict(district) {
return {
type: UPDATE_DISTRICT,
district
};
}
export const UPDATE_FILTERS = 'UPDATE_FILTERS';
export function updateFilters(filters) {
return {
type: UPDATE_FILTERS,
filters: filters
};
}
export const UPDATE_TIMERANGE = 'UPDATE_TIMERANGE';
export function updateTimeRange(range) {
return {
type: UPDATE_TIMERANGE,
range
};
}
export const RESET_ALLFILTERS = 'RESET_ALLFILTERS';
export function resetAllFilters() {
return {
type: RESET_ALLFILTERS
};
}
// UI
export const TOGGLE_FETCHING_DOMAIN = 'TOGGLE_FETCHING_DOMAIN';
export function toggleFetchingDomain() {
return {
type: TOGGLE_FETCHING_DOMAIN
};
}
export const TOGGLE_FETCHING_EVENTS = 'TOGGLE_FETCHING_EVENTS';
export function toggleFetchingEvents() {
return {
type: TOGGLE_FETCHING_EVENTS
};
}
export const TOGGLE_VIEW = 'TOGGLE_VIEW';
export function toggleView() {
return {
type: TOGGLE_VIEW
};
}
export const TOGGLE_TIMELINE = 'TOGGLE_TIMELINE';
export function toggleTimeline() {
return {
type: TOGGLE_TIMELINE
};
}
export const TOGGLE_LANGUAGE = 'TOGGLE_LANGUAGE';
export function toggleLanguage(language) {
return {
type: TOGGLE_LANGUAGE,
language,
}
}
export const OPEN_TOOLBAR = 'OPEN_TOOLBAR';
export function openToolbar(toolbarTab = 0) {
return {
type: OPEN_TOOLBAR,
toolbarTab: toolbarTab,
};
}
export const CLOSE_TOOLBAR = 'CLOSE_TOOLBAR';
export function closeToolbar() {
return {
type: CLOSE_TOOLBAR
};
}
export const OPEN_CABINET = 'OPEN_CABINET';
export function openCabinet(tabNum) {
return {
type: OPEN_CABINET,
tabNum: tabNum,
};
}
export const CLOSE_CABINET = 'CLOSE_CABINET';
export function closeCabinet() {
return {
type: CLOSE_CABINET
};
}
export const TOGGLE_INFOPOPUP = 'TOGGLE_INFOPOPUP';
export function toggleInfoPopup() {
return {
type: TOGGLE_INFOPOPUP
};
}
export const TOGGLE_NOTIFICATIONS = 'TOGGLE_NOTIFICATIONS';
export function toggleNotifications() {
return {
type: TOGGLE_NOTIFICATIONS
};
}

8
src/assets/arrowdown.svg Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="595.28px" height="841.89px" viewBox="0 0 595.28 841.89" enable-background="new 0 0 595.28 841.89" xml:space="preserve">
<line fill="#FFFFFF" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" x1="1.688" y1="1.884" x2="7.303" y2="7.499"/>
<line fill="#FFFFFF" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" x1="7.303" y1="7.499" x2="12.803" y2="2"/>
</svg>

After

Width:  |  Height:  |  Size: 842 B

7
src/assets/checkbox.svg Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="595.28px" height="841.89px" viewBox="0 0 595.28 841.89" enable-background="new 0 0 595.28 841.89" xml:space="preserve">
<rect x="1.076" y="0.983" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" width="15" height="15"/>
</svg>

After

Width:  |  Height:  |  Size: 669 B

8
src/assets/close.svg Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="595.28px" height="841.89px" viewBox="0 0 595.28 841.89" enable-background="new 0 0 595.28 841.89" xml:space="preserve">
<line fill="#FFFFFF" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" x1="1.224" y1="1.125" x2="11.831" y2="11.732"/>
<line fill="#FFFFFF" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" x1="1.419" y1="11.537" x2="12.027" y2="0.93"/>
</svg>

After

Width:  |  Height:  |  Size: 848 B

16
src/components/App.jsx Normal file
View File

@@ -0,0 +1,16 @@
import '../scss/main.scss';
import React from 'react';
import Dashboard from './Dashboard.jsx';
class App extends React.Component {
render() {
return (
<div>
<Dashboard />
</div>
);
}
}
export default App;

230
src/components/Card.jsx Normal file
View File

@@ -0,0 +1,230 @@
import '../scss/main.scss';
import copy from '../js/data/copy.json';
import {isNotNullNorUndefined} from '../js/data/utilities';
import React from 'react';
class Card extends React.Component {
constructor(props) {
super(props);
this.state = {
isFolded: true
};
this.toggle = this.toggle.bind(this);
}
toggle() {
if (this.state.isFolded) {
this.props.highlight(this.props.event);
} else {
this.props.highlight();
}
this.setState({
isFolded: !this.state.isFolded
});
}
getCategoryColorClass(category) {
if (category)
return this.props.getCategoryGroup(category);
return 'other';
}
renderWarning() {
const warning_lang = copy[this.props.language].cardstack.warning;
if (this.props.event.tags) {
const tagsArray = this.props.event.tags.split(",");
/* TODO: This needs to be generalized */
if (tagsArray.some(tag => {
return (tag.name === 'contradicción' ||
tag.name === 'declaración con sospecha de tortura')
})) {
return (<div className="warning event-card-section">{warning_lang}</div>);
}
}
}
renderCategory() {
const category_lang = copy[this.props.language].cardstack.category;
const colorType = this.getCategoryColorClass(this.props.event.category);
const categoryLabel = this.props.getCategoryLabel(this.props.event.category);
return (<div className="event-card-section category">
<h4>{category_lang}</h4>
<p><span className={`color-category ${colorType}`}/>{categoryLabel}</p>
</div>);
}
// NB: is this function for a future feature?
renderIncidents() {
const incident_type_lang = copy[this.props.language].cardstack.incident_type;
const incidentTags = []; //this.props.event.tags.filter(tag => tag.type === 'incident_type');
return (<div className="event-card-section event-type">
<h4>{incident_type_lang}</h4>
{
incidentTags.map((tag, idx) => {
return (<span className={(
tag.name === 'contradicción' || tag.name === 'declaración con sospecha de tortura')
? ' flagged'
: ''}>
{tag.name}{
(idx < incidentTags.length - 1)
? ','
: ''
}
</span>);
})
}
</div>);
}
renderSummary() {
const summary = copy[this.props.language].cardstack.description;
const desc = this.props.event.description;
const description = (this.state.isFolded) ? `${desc.substring(0, 40)}...` : desc;
return (<div className="event-card-section summary">
<h4>{summary}</h4>
<p>{description}</p>
</div>);
}
renderTags() {
const people_lang = copy[this.props.language].cardstack.people;
const peopleTags = []; //this.props.event.tags.filter(tag => tag.type === 'people');
return (<div className="event-card-section tags">
<h4>{people_lang}</h4>
<p>{
peopleTags.map((tag, idx) => {
return (<span className="tag">
{tag.name}
{
(idx < peopleTags.length - 1)
? ','
: ''
}
</span>);
})
}</p>
</div>);
}
// NB: is this function for a future feature? Should also be internaionalized.
renderLocation() {
const location_lang = copy[this.props.language].cardstack.location;
if (isNotNullNorUndefined(this.props.event.location)) {
return (<p className="event-card-section location">
<h4>{location_lang}</h4>
<p>{this.props.event.location}</p>
</p>);
} else {
return (<p className="event-card-section location">
<h4>{location_lang}</h4>
<p>Sin localización conocida.</p>
</p>);
}
}
renderSource() {
const source_lang = copy[this.props.language].cardstack.source;
return (<div className="event-card-section source">
<h4>{source_lang}</h4>
<p>{this.props.event.source}</p>
</div>);
}
// NB: should be internaionalized.
renderTimestamp() {
const daytime_lang = copy[this.props.language].cardstack.timestamp;
const estimated_lang = copy[this.props.language].cardstack.estimated;
if (isNotNullNorUndefined(this.props.event.timestamp)) {
const timestamp = this.props.tools.parser(this.props.event.timestamp);
const timelabel = this.props.tools.formatterWithYear(timestamp);
return (<div className="event-card-section timestamp">
<h4>{daytime_lang}</h4>
{timelabel}
</div>);
} else {
return (<div className="event-card-section timestamp">
<h4>{daytime_lang}</h4>
Hora no conocida
</div>);
}
}
renderHeader() {
return (<div className="card-collapsed">
{this.renderWarning()}
{this.renderCategory()}
{this.renderTimestamp()}
{this.renderSummary()}
</div>);
}
renderContent() {
if (this.state.isFolded) {
return (<div className="card-bottomhalf folded"></div>);
} else if (this.props.isFetchingEvents) {
return (<div className="card-bottomhalf">
{this.renderSpinner()}
</div>);
} else {
if (!this.props.event.hasOwnProperty('receiver') && !this.props.event.hasOwnProperty('transmitter')) {
return (<div className="card-bottomhalf">
{this.renderTimestamp()}
{this.renderLocation()}
{this.renderTags()}
{this.renderSource()}
</div>);
} else {
return (<div className="card-bottomhalf">
{this.renderTimestamp()}
{this.renderTags()}
{this.renderSource()}
</div>);
}
}
}
renderSpinner() {
return (<div className="spinner">
<div className="double-bounce1"></div>
<div className="double-bounce2"></div>
</div>);
}
renderArrow() {
let classes = (this.state.isFolded)
? 'arrow-down folded'
: 'arrow-down';
return (<div className="card-toggle" onClick={() => this.toggle()}>
<p>
<i className={classes}></i>
</p>
</div>);
}
render() {
if (this.props.isLoading) {
return (<li className='event-card'>
<div className="card-bottomhalf">
{this.renderSpinner()}
</div>
</li>);
} else {
return (<li className='event-card'>
{this.renderHeader()}
{this.renderContent()}
{this.renderArrow()}
</li>);
}
}
}
export default Card;

View File

@@ -0,0 +1,99 @@
import '../scss/main.scss';
import React from 'react';
import Card from './Card.jsx';
import copy from '../js/data/copy.json';
import {
isNotNullNorUndefined
} from '../js/data/utilities.js';
class CardStack extends React.Component {
constructor(props) {
super(props);
}
renderCards() {
if (this.props.selected.length > 0) {
return this.props.selected.map((event) => {
// if event has property 'name', update with event details
const shouldCardUpdate = (event.name);
return (
<Card
event={event}
shouldCardUpdate={shouldCardUpdate}
language={this.props.language}
tools={this.props.tools}
isFetchingEvents={this.props.isFetchingEvents}
getCategoryGroup={this.props.getCategoryGroup}
getCategoryGroupColor={this.props.getCategoryGroupColor}
getCategoryLabel={this.props.getCategoryLabel}
highlight={this.props.highlight}
/>
);
});
} else {
return '';
}
}
renderLocation() {
let locationName = copy[this.props.language].cardstack.unknown_location;
if (this.props.selected.length > 0) {
if (isNotNullNorUndefined(this.props.selected[0].location)) {
locationName = this.props.selected[0].location;
}
return (<p className="header-copy">in:<b>{` ${locationName}`}</b></p>)
}
return '';
}
render() {
const header_lang = copy[this.props.language].cardstack.header;
if (this.props.isFetchingEvents) {
return (
<div id="card-stack" className={`card-stack ${this.props.isCardstack ? '' : ' folded'}`}>
<div
id='card-stack-header'
className='card-stack-header'
onClick={() => this.props.toggle('TOGGLE_CARDSTACK')}
>
<button className="side-menu-burg is-active"><span></span></button>
<p className="header-copy top">{copy[this.props.language].loading}</p>
</div>
<div id="card-stack-content" className="card-stack-content">
<ul>
<Card
language={this.props.language}
isLoading={true}
/>
</ul>
</div>
</div>
);
} else if (this.props.selected.length > 0) {
return (
<div id="card-stack" className={`card-stack ${this.props.isCardstack ? '' : ' folded'}`}>
<div
id='card-stack-header'
className='card-stack-header'
onClick={() => this.props.toggle('TOGGLE_CARDSTACK')}
>
<button className="side-menu-burg is-active"><span></span></button>
<p className="header-copy top">{`${this.props.selected.length} ${header_lang}`}</p>
{this.renderLocation()}
</div>
<div id="card-stack-content" className="card-stack-content">
<ul>
{this.renderCards()}
</ul>
</div>
</div>
);
}
return <div/>;
}
}
export default CardStack;

View File

@@ -0,0 +1,11 @@
import '../scss/main.scss';
import React from 'react';
export default ({ label, isActive, onClickLabel, onClickCheckbox }) => (
<div className={(isActive) ? 'item active' : 'item'}>
<span onClick={() => onClickLabel()}>{label}</span>
<button onClick={() => onClickCheckbox()}>
<div className="checkbox" />
</button>
</div>
);

View File

@@ -0,0 +1,236 @@
import '../scss/main.scss';
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as actions from '../actions';
import * as selectors from '../selectors';
import LoadingOverlay from './LoadingOverlay.jsx';
import Viewport from './Viewport.jsx';
import Toolbar from './Toolbar.jsx';
import CardStack from './CardStack.jsx';
import InfoPopUp from './InfoPopup.jsx';
import Timeline from './Timeline.jsx';
import Notification from './Notification.jsx';
class Dashboard extends React.Component {
constructor(props) {
super(props);
this.handleHighlight = this.handleHighlight.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.handleToggle = this.handleToggle.bind(this);
this.handleFilter = this.handleFilter.bind(this);
}
componentDidMount() {
if (!this.props.app.isMobile) {
this.props.actions.fetchDomain()
.then((domain) => this.props.actions.updateDomain(domain));
}
}
handleHighlight(highlighted) {
this.props.actions.updateHighlighted((highlighted) ? highlighted : null);
}
handleSelect(selected) {
if (selected) {
// attacks are not susceptible to tag filters, so make sure this happens only when they are found
// in the domain
let eventsToSelect = selected.map(eventId => this.props.domain.events[eventId]);
eventsToSelect = eventsToSelect.sort((a, b) => {
return this.props.ui.tools.parser(a.timestamp) - this.props.ui.tools.parser(b.timestamp);
});
if (eventsToSelect.every(event => (event))) {
this.props.actions.updateSelected(eventsToSelect);
}
// Now fetch detail data for each event
// Add transmitter and receiver data for coevents
this.props.actions.fetchEvents(selected)
.then((events) => {
let eventsSelected = events.map(ev => {
return Object.assign({}, ev, this.props.domain.events[ev.id]);
});
eventsSelected = eventsSelected.sort((a, b) => {
return this.props.ui.tools.parser(a.timestamp) - this.props.ui.tools.parser(b.timestamp);
});
this.props.actions.updateSelected(eventsSelected);
});
} else {
this.props.actions.updateSelected([]);
}
}
handleFilter(filters) {
this.props.actions.updateFilters(filters);
}
handleToggle( key ) {
switch( key ) {
case 'TOGGLE_CARDSTACK': {
this.props.actions.updateSelected([]);
break;
}
case 'TOGGLE_INFOPOPUP': {
this.props.actions.toggleInfoPopup();
break;
}
case 'TOGGLE_NOTIFICATIONS': {
this.props.actions.toggleNotifications();
break;
}
}
}
getCategoryGroup(category) {
const cat = this.props.domain.categories.find(t => t.category === category)
if (cat) return cat.group;
return 'other';
}
getCategoryGroupColor(category) {
const group = this.getCategoryGroup(category);
return this.props.ui.style.groupColors[group];
}
getCategoryLabel(category) {
const label = this.props.domain.categories.find(t => t.category === category).category_label;
return label;
}
renderTool() {
return (<div>
<Viewport
locations={this.props.domain.locations}
sites={this.props.domain.sites}
categoryGroups={this.props.domain.categoryGroups}
views={this.props.app.filters.views}
selected={this.props.app.selected}
highlighted={this.props.app.highlighted}
mapAnchor={this.props.app.mapAnchor}
uiStyle={this.props.ui.style}
dom={this.props.ui.dom}
isView2d={this.props.ui.flags.isView2d}
select={this.handleSelect}
highlight={this.handleHighlight}
getCategoryGroup={category => this.getCategoryGroup(category)}
getCategoryGroupColor={category => this.getCategoryGroupColor(category)}
/>
<Toolbar
tags={this.props.domain.tags}
categories={this.props.domain.categories}
language={this.props.app.language}
tagFilters={this.props.app.filters.tags}
categoryFilter={this.props.app.filters.categories}
viewFilters={this.props.app.filters.views}
features={this.props.app.features}
isToolbar={this.props.ui.flags.isToolbar}
toolbarTab={this.props.ui.components.toolbarTab}
isView2d={this.props.ui.flags.isView2d}
filter={this.handleFilter}
toggle={ (key) => this.handleToggle(key) }
actions={this.props.actions}
/>
<CardStack
selected={this.props.app.selected}
language={this.props.app.language}
tools={this.props.ui.tools}
isCardstack={this.props.ui.flags.isCardstack}
isFetchingEvents={this.props.ui.flags.isFetchingEvents}
highlight={this.handleHighlight}
filter={this.handleFilter}
toggle={this.handleToggle}
getCategoryGroup={category => this.getCategoryGroup(category)}
getCategoryGroupColor={category => this.getCategoryGroupColor(category)}
getCategoryLabel={category => this.getCategoryLabel(category)}
/>
<Timeline
events={this.props.domain.events.filter(item => item)}
categoryGroups={this.props.domain.categoryGroups}
range={this.props.app.filters.range}
selected={this.props.app.selected}
language={this.props.app.language}
tools={this.props.ui.tools}
dom={this.props.ui.dom}
select={this.handleSelect}
filter={this.handleFilter}
highlight={this.handleHighlight}
toggle={() => this.handleToggle('TOGGLE_CARDSTACK')}
getCategoryGroup={category => this.getCategoryGroup(category)}
getCategoryGroupColor={category => this.getCategoryGroupColor(category)}
getCategoryLabel={category => this.getCategoryLabel(category)}
/>
<InfoPopUp
ui={this.props.ui}
app={this.props.app}
toggle={() => this.handleToggle('TOGGLE_INFOPOPUP')}
/>
<Notification
isNotification={this.props.ui.flags.isNotification}
notifications={this.props.domain.notifications}
toggle={() => this.handleToggle('TOGGLE_NOTIFICATIONS')}
/>
<LoadingOverlay
ui={this.props.ui}
language={this.props.app.language}
/>
</div>
)
}
render() {
return (<div>{this.renderTool()}</div>);
}
}
function mapStateToProps(state) {
return Object.assign({}, state, {
domain: Object.assign({}, state.domain, {
events: selectors.getFilteredEvents(state),
locations: selectors.getFilteredLocations(state),
categories: selectors.getFilteredCategories(state),
categoryGroups: selectors.getCategoryGroups(state),
sites: selectors.getSites(state),
tags: selectors.getTags(state),
notifications: state.domain.notifications,
}),
app: Object.assign({}, state.app, {
error: state.app.error,
filters: Object.assign({}, state.app.filters, {
range: selectors.getRangeFilter(state),
tags: selectors.getTagFilters(state)
})
}),
ui: state.ui
});
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(actions, dispatch)
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(Dashboard);

83
src/components/Icon.jsx Normal file
View File

@@ -0,0 +1,83 @@
import React from 'react';
const Icon = ({ iconType }) => {
if (iconType === 'personas') {
return (
<svg x="0px" y="0px" width="40px" height="40px" viewBox="0 0 40 40" enableBackground="new 0 0 40 40">
<path d="M15.464,17.713" />
<path d="M5.526,17.713c-1.537,0.595-3,1.472-4.314,2.637l1.114,17.081h16.338" />
<path d="M12.283,15.522c-1.707,0.661-3.332,1.636-4.792,2.93l1.238,18.979h18.153" />
<circle cx="27.432" cy="8.876" r="6.877" />
<path d="M21.297,13.088c-1.896,0.733-3.702,1.817-5.326,3.256l1.375,21.087h20.17l1.376-21.087c-1.624-1.438-3.43-2.522-5.326-3.256" />
<path d="M20.968,6.547c-0.926-0.554-2.006-0.877-3.163-0.877c-3.418,0-6.188,2.771-6.188,6.188c0,2.811,1.875,5.18,4.441,5.935" />
<path d="M12.38,8.881c-0.738-0.361-1.564-0.57-2.441-0.57c-3.076,0-5.57,2.494-5.57,5.57c0,1.983,1.04,3.72,2.601,4.707" />
</svg>
);
} else if (iconType === 'tipos') {
return (
<svg x="0px" y="0px" width="40px" height="40px" viewBox="0 0 40 40" enableBackground="new 0 0 40 40">
<path strokeDasharray="3, 4" d="M22.326,5.346
c-2.154-2.081-5.082-3.367-8.314-3.367c-6.614,0-11.976,5.361-11.976,11.974c0,6.613,5.361,11.977,11.976,11.977
c0.228,0,0.449-0.021,0.674-0.034"/>
<circle cx="23" cy="17.288" r="11.975"/>
<circle strokeDasharray="3, 4" cx="25.987" cy="26.926" r="11.976" />
</svg>
);
} else if (iconType === 'hardware') {
return (
<svg x="0px" y="0px" width="40px" height="40px" viewBox="0 0 40 40" enableBackground="new 0 0 40 40">
<path d="M20,1.695C12.571,1.696,6.286,2.019,5.272,2.452C5.253,2.458,5.233,2.466,5.215,2.474
c-0.01,0.004-0.019,0.008-0.027,0.012C4.38,2.831,3.803,4.256,3.802,5.907v3.502H2.926H1.175c-0.241,0-0.438,0.196-0.438,0.438
v0.875v5.254c0,0.242,0.196,0.438,0.438,0.438h1.751c0.242,0,0.438-0.195,0.438-0.438V11.16h0.438v15.324h5.691
c0.242,0,0.438,0.195,0.438,0.438v1.751c0,0.241-0.195,0.438-0.438,0.438H3.802v3.063c0,0.626,0.167,1.203,0.438,1.515v3.74
c0,0.482,0.393,0.875,0.876,0.875h2.627c0.483,0,0.875-0.393,0.875-0.875v-2.627h22.765v2.627c0,0.482,0.393,0.875,0.877,0.875
h2.627c0.482,0,0.875-0.393,0.875-0.875v-3.74c0.271-0.312,0.438-0.889,0.438-1.515v-3.065h-5.691c-0.241,0-0.438-0.195-0.438-0.438
v-1.751c0-0.241,0.197-0.438,0.438-0.438H36.2V11.161h0.438v4.816c0,0.242,0.195,0.438,0.438,0.438h1.752
c0.24,0,0.438-0.195,0.438-0.438v-5.254V9.848c0-0.242-0.195-0.438-0.438-0.438h-1.752h-0.875V5.907
c-0.001-1.703-0.614-3.159-1.453-3.448C33.79,2.023,27.479,1.696,20,1.695z M5.429,3.28h29.144c0.483,0,0.875,0.98,0.875,2.189l0,0
V7.22c0,0.242-0.195,0.438-0.438,0.438H4.991c-0.242,0-0.438-0.196-0.438-0.438V5.469C4.553,4.261,4.945,3.28,5.429,3.28z
M5.553,8.534h28.895c0.483,0,0.876,0.392,0.876,0.875v13.134c0,0.484-0.393,0.876-0.876,0.876h-3.466c0,0-0.863,0.613-0.912,0.613
H9.931c-0.113,0-0.225-0.022-0.33-0.065l-0.778-0.548h-3.27c-0.483,0-0.875-0.392-0.875-0.876V9.409
C4.678,8.926,5.069,8.534,5.553,8.534L5.553,8.534z"/>
</svg>
);
} else if (iconType === 'escenas') {
return (
<svg className="scenes" x="0px" y="0px" width="40px" height="40px" viewBox="0 0 40 40" enableBackground="new 0 0 40 40">
<path d="M36.729,14.743v13.15l-14.225,6.693V21.438L36.729,14.743 M38.732,11.045L20.5,19.625v18.662l18.232-8.58V11.045
L38.732,11.045z" />
<path d="M4.271,14.743l14.225,6.695v13.148L4.271,27.894V14.743 M2.268,11.045v18.662l18.232,8.58V19.625L2.268,11.045L2.268,11.045
z" />
<path d="M20.5,4.844l13.289,6.202L20.5,17.247L7.209,11.046L20.5,4.844 M20.5,2.537L2.268,11.045L20.5,19.554l18.232-8.509
L20.5,2.537L20.5,2.537z" />
</svg>
);
} else if (iconType === 'docs') {
return (
<svg x="0px" y="0px" width="40px" height="40px" viewBox="0 0 40 40" enableBackground="new 0 0 40 40">
<path d="M31.543,5.987V3.158
c0-1.103-0.095-1.197-1.197-1.197H4.791c-1.103,0-1.198,0.095-1.198,1.197V32.84c0,1.103,0.095,1.197,1.198,1.197h2.829"/>
<path d="M35.57,36.866
c0,1.103-0.096,1.198-1.198,1.198H8.817c-1.103,0-1.198-0.096-1.198-1.198V7.185c0-1.103,0.095-1.197,1.198-1.197h25.555
c1.103,0,1.198,0.095,1.198,1.197V36.866z"/>
<path d="M58.755,29.633"/>
<path d="M21.86,40.072"/>
<path d="M-22.755,58.555"/>
<line x1="11.612" y1="11.977" x2="31.577" y2="11.977"/>
<line x1="11.612" y1="17.966" x2="31.577" y2="17.966"/>
<line x1="11.612" y1="29.945" x2="31.577" y2="29.945"/>
<line x1="11.612" y1="23.955" x2="31.577" y2="23.955"/>
</svg>
)
} else if (iconType === 'search') {
return (
<svg x="0px" y="0px" width="40px" height="40px" viewBox="0 0 40 40" enableBackground="new 0 0 40 40">
<circle cx="18.306" cy="18.307" r="13.856"/>
<path strokeLinecap="round" strokeLinejoin="round" d="M28.24,28.24
l8.346,8.346L28.24,28.24z"/>
</svg>
);
}
}
export default Icon;

View File

@@ -0,0 +1,104 @@
import React from 'react';
import copy from '../js/data/copy.json';
// NB: should we make this componetn part of a future feature?
export default class InfoPopUp extends React.Component{
renderView2DCopy() {
return copy[this.props.app.language].legend.view2d.paragraphs.map(paragraph => <p>{paragraph}</p>);
}
renderCategoryColors() {
const colors = copy[this.props.app.language].legend.view2d.colors.slice(0);
colors.reverse();
return (
<div className="legend-labels" style={{ 'margin-left': '-10px' }}>
{colors.map((color, idx) => {
return (
<div className="label" style={{ 'margin-left': `${idx*5}` }}>
<div className={`color-category ${color.class}`}></div>
{color.label}
</div>
)
})}
</div>
)
}
renderView2DLegend() {
return (
<div className={`infopopup ${(this.props.ui.flags.isInfopopup) ? '' : 'hidden'}`}>
<button onClick={() => this.props.toggle()} className="side-menu-burg over-white is-active"><span /></button>
{this.renderView2DCopy()}
<div className="legend">
<div className="legend-section" style={{ 'height': '100px' }}>
<svg x="0px" y="0px" width="100px" height="100px" viewBox="0 0 100 100" enableBackground="new 0 0 100 100">
<circle fill="#D2CD28" cx="50" cy="50" r="50"/>
<circle fill="#662770" cx="50" cy="50" r="40"/>
<circle fill="#2F409A" cx="50" cy="50" r="30"/>
<circle fill="#256C36" cx="50" cy="50" r="20"/>
<circle fill="#FF0000" cx="50" cy="50" r="10"/>
</svg>
{this.renderCategoryColors()}
</div>
<div className="legend-section">
<svg x="0px" y="0px" width="100px" height="30px" viewBox="0 0 100 30" enableBackground="new 0 0 100 30">
<line fill="none" stroke="#2F409A" strokeDasharray="4,4" x1="30" y1="15" x2="70" y2="15"/>
<circle fill="2F409A" fillOpacity="0.2" stroke="#2F409A" strokeDasharray="4,4" cx="80" cy="15" r="10"/>
<circle fill="2F409A" fillOpacity="0.2" stroke="#2F409A" strokeDasharray="4,4" cx="20" cy="15" r="10"/>
</svg>
<div className="legend-labels">
<div className="label">Comunicaciones</div>
</div>
</div>
<div className="legend-section">
<svg x="0px" y="0px" width="100px" height="30px" viewBox="0 0 100 30" enableBackground="new 0 0 100 30">
<circle opacity="0.3" fill="#FF0000" cx="50" cy="15" r="15"/>
</svg>
<div className="legend-labels">
<div className="label">Ataques</div>
</div>
</div>
<div className="legend-section">
<svg x="0px" y="0px" width="100px" height="30px" viewBox="0 40 100 30" enableBackground="new 0 0 100 70">
<polyline fill="none" stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" stroke-miterlimit="10" points="
8.376,63.723 47.287,63.723 60,46 106,46 "/>
<line stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" x1="33.723" y1="59.663" x2="39.069" y2="63.723"/>
<line stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" x1="33.723" y1="67.782" x2="39.069" y2="63.723"/>
<line stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" x1="78.849" y1="41.94" x2="84.195" y2="46"/>
<line stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" x1="78.849" y1="50.06" x2="84.195" y2="46"/>
</svg>
<div className="legend-labels">
<div className="label">Rutas de bus</div>
</div>
</div>
</div>
</div>
)
}
renderView3DLegend() {
const lang = copy[this.props.app.language].legend.view3d;
return (
<div className={`infopopup ${(this.props.ui.flags.isInfopopup) ? '' : 'hidden'}`}>
<button onClick={() => this.props.toggle()} className="side-menu-burg over-white is-active"><span /></button>
{lang.paragraphs.map(paragraph => <p>{paragraph}</p>)}
{lang.colors.map(color => (
<div className="legend-item">
<div className={`color-marker ${color.class}`}></div>
<div className="item-label">{color.label}</div>
</div>
))}
</div>
)
}
render() {
if (!this.props.ui.flags.isView2d) {
return (<div>{this.renderView3DLegend()}</div>)
}
return (
<div>{this.renderView2DLegend()}</div>
)
}
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import copy from '../js/data/copy.json';
const LoadingOverlay = ({ ui, language }) => {
let classes = 'loading-overlay';
classes += (!ui.flags.isFetchingDomain) ? ' hidden' : '';
return (
<div id="loading-overlay" className={classes}>
<div className="loading-wrapper">
<span id="loading-text" className="text">{copy[language].loading}</span>
<div className="spinner">
<div className="double-bounce1" />
<div className="double-bounce2" />
</div>
</div>
</div>
);
};
export default LoadingOverlay;

View File

@@ -0,0 +1,48 @@
import React from 'react';
export default class Notification extends React.Component{
constructor(props) {
super();
this.state = {
isExtended: false
}
}
toggleDetails() {
this.setState({ isExtended: !this.state.isExtended });
}
renderItems(items) {
if (!items) return '';
return (
<div>
{items.map((item) => {
if (item.error) {
return (<p>{item.error.message}</p>);
}
return '';
})}
</div>
)
}
render() {
if (this.props.isNotification) {
return (
<div className={`notification-wrapper`}>
{this.props.notifications.map(not => (
<div className='notification' onClick={() => this.toggleDetails() }>
<button onClick={() => this.props.toggle()} className="side-menu-burg over-white is-active"><span /></button>
<div className={`message ${not.type}`}>{`${not.message}`}</div>
<div className={`details ${this.state.isExtended}`}>
{(not.items !== null) ? this.renderItems(not.items) : ''}
</div>
</div>
))}
</div>
)
}
return (<div/>);
}
}

71
src/components/Search.jsx Normal file
View File

@@ -0,0 +1,71 @@
import React from 'react';
import copy from '../js/data/copy.json';
import TagFilter from './TagFilter.jsx';
export default class Search extends React.Component {
constructor(props) {
super(props);
this.state = {
searchValue: undefined,
searchResults: []
}
this.handleSearchChange = this.handleSearchChange.bind(this);
this.handleSearchSubmit = this.handleSearchSubmit.bind(this);
}
handleSearchSubmit(e) {
e.preventDefault();
fetch(`api/search/${this.state.searchValue}`)
.then(response => response.json())
.then(json => {
this.setState({
searchResults: json.tags
})
});
}
handleSearchChange(event) {
this.setState({ searchValue: event.target.value });
}
renderSearchResults() {
return (
this.state.searchResults.map(tag => {
return (
<TagFilter
isShowTree={true}
tags={this.props.tags}
categories={this.props.categories}
tagFilters={this.props.tagFilters}
categoryFilters={this.props.categoryFilters}
filter={this.props.filter}
tag={tag}
isCategory={this.props.isCategory}
/>
);
})
);
}
render() {
return (
<div className="search-content">
<h2>{copy[this.props.language].toolbar.panels.search.title}</h2>
<form onSubmit={this.handleSearchSubmit}>
<input
value={this.state.searchValue}
onChange={this.handleSearchChange}
autoFocus
type="text"
name="search-input"
placeholder={copy[this.props.language].toolbar.panels.search.placeholder}
/>
</form>
<ul>
{this.renderSearchResults()}
</ul>
</div>
);
}
}

View File

@@ -0,0 +1,91 @@
import '../scss/main.scss';
import React from 'react';
import Checkbox from './Checkbox.jsx';
class TagFilter extends React.Component {
constructor(props) {
super(props);
}
isActive() {
if (this.props.isCategory) {
return this.props.categoryFilters.includes(this.props.tag.id);
}
return this.props.tagFilters.includes(this.props.tag.id);
}
onClickTag() {
if (this.isActive()) {
this.props.filter({
tags: this.props.tagFilters.filter(element => element !== this.props.tag.id)
});
} else {
this.props.filter({
tags: this.props.tagFilters.concat(this.props.tag.id)
});
}
}
onClickCategory() {
if (this.isActive()) {
this.props.filter({
categories: this.props.categoryFilters.filter(element => element !== this.props.tag.id)
});
} else {
this.props.filter({
categories: this.props.categoryFilters.concat(this.props.tag.id)
});
}
}
renderTag() {
const tag = this.props.tag;
let classes = (this.isActive()) ? 'tag-filter active' : 'tag-filter';
let label = `${tag.name} ( ${tag.mentions} )`;
if (this.props.isShowTree) {
label = `${tag.group} > ${tag.subgroup} > ${tag.name} ( ${tag.mentions} )`;
}
return (
<li
key={this.props.tag.id}
className={classes}
>
<Checkbox
isActive={this.isActive()}
label={label}
onClickLabel={() => this.onClickTag()}
onClickCheckbox={() => this.onClickTag()}
/>
</li>
);
}
renderCategory() {
const category = this.props.categories[this.props.tag.id];
let classes = (this.isActive()) ? 'tag-filter active' : 'tag-filter';
if (category) {
return (
<li
key={this.props.tag.id}
className={classes}
>
<Checkbox
isActive={this.isActive()}
label={`${category.name} ( ${category.counts} )`}
onClickLabel={() => this.onClickCategory()}
onClickCheckbox={() => this.onClickCategory()}
/>
</li>
);
}
return (<div/>);
}
render() {
if (this.props.isCategory) return (this.renderCategory());
return (this.renderTag());
}
}
export default TagFilter;

View File

@@ -0,0 +1,94 @@
import '../scss/main.scss';
import React from 'react';
import Checkbox from './Checkbox.jsx';
class TagListPanel extends React.Component {
constructor(props) {
super(props);
this.state = {
treeComponents: []
}
this.treeComponents = [];
this.newTagFilters = [];
}
componentDidMount() {
this.computeTree(this.props.tags.children[this.props.tagType]);
}
componentWillReceiveProps(nextProps) {
this.computeTree(nextProps.tags.children[nextProps.tagType]);
}
traverseNodeAndCheckIt(node, depth, active) {
// do something to node
const tagFilter = this.newTagFilters.find(tagFilter => tagFilter.key === node.key)
tagFilter.active = (depth === 0) ? !node.active : active;
tagFilter.depth = depth;
depth = depth + 1;
if (Object.keys(tagFilter.children).length > 0) {
Object.values(tagFilter.children).forEach((childNode) => {
this.traverseNodeAndCheckIt(childNode, depth, tagFilters, tagFilter.active);
});
}
}
onClickCheckbox(tag) {
this.newTagFilters = this.props.tagFilters.slice(0);
let depth = 0;
if (tag.key && tag.children) this.traverseNodeAndCheckIt(tag, depth);
this.props.filter({ tags: this.newTagFilters });
}
createNodeComponent (node, depth) {
return (
<li
key={node.key.replace(/ /g,"_")}
className={'tag-filter active'}
style={{ marginLeft: `${depth*20}px` }}
>
<Checkbox
label={node.key}
isActive={node.active}
onClickCheckbox={() => this.onClickCheckbox(node)}
/>
</li>
);
}
traverseNodeAndCreateComponent(node, depth) {
// add and create node component
const newComponent = this.createNodeComponent(node, depth);
this.treeComponents.push(newComponent)
depth = depth + 1;
if (Object.keys(node.children).length > 0) {
Object.values(node.children).forEach((childNode) => {
this.traverseNodeAndCreateComponent(childNode, depth);
});
}
}
computeTree (node) {
this.treeComponents = [];
let depth = 0;
this.traverseNodeAndCreateComponent(node, depth);
this.setState({ treeComponents: this.treeComponents });
}
renderTree() {
return this.state.treeComponents.map(c => c);
}
render() {
return (
<div className="react-innertabpanel">
{this.renderTree()}
</div>
);
}
}
export default TagListPanel;

View File

@@ -0,0 +1,97 @@
import '../scss/main.scss';
import copy from '../js/data/copy.json';
import React from 'react';
import TimelineLogic from '../js/timeline/timeline.js';
class Timeline extends React.Component {
constructor(props) {
super(props);
this.state = {isFolded: false};
}
componentDidMount() {
const domain = {
events: this.props.events,
categoryGroups: this.props.categoryGroups
}
const app = {
range: this.props.range,
selected: this.props.selected,
language: this.props.language,
select: this.props.select,
filter: this.props.filter,
getCategoryLabel: this.props.getCategoryLabel,
getCategoryGroup: this.props.getCategoryGroup,
getCategoryGroupColor: this.props.getCategoryGroupColor
}
const ui = {
tools: this.props.tools,
dom: this.props.dom
}
this.timeline = new TimelineLogic(app, ui);
this.timeline.update(domain, app);
this.timeline.render(domain);
}
componentWillReceiveProps(nextProps) {
const domain = {
events: nextProps.events,
categoryGroups: nextProps.categoryGroups
}
const app = {
range: nextProps.range,
selected: nextProps.selected,
language: nextProps.language,
select: nextProps.select,
filter: nextProps.filter,
getCategoryLabel: nextProps.getCategoryLabel,
getCategoryGroup: nextProps.getCategoryGroup,
getCategoryGroupColor: nextProps.getCategoryGroupColor
}
this.timeline.update(domain, app);
this.timeline.render(domain);
}
onClickArrow() {
this.setState((prevState, props) => {
return {isFolded: !prevState.isFolded};
});
}
renderLabels() {
const labels = copy[this.props.language].timeline.labels;
return this.props.categoryGroups.map((label) => {
const groupLen = this.props.categoryGroups.length
return (<div className="timeline-label">{label}</div>);
});
}
render() {
const labels_title_lang = copy[this.props.language].timeline.labels_title;
const info_lang = copy[this.props.language].timeline.info;
let classes = `timeline-wrapper ${(this.state.isFolded) ? ' folded' : ''}`;
const date0 = this.props.tools.formatterWithYear(this.props.range[0]);
const date1 = this.props.tools.formatterWithYear(this.props.range[1]);
return (
<div className={classes}>
<div className="timeline-header">
<div className="timeline-toggle" onClick={() => this.onClickArrow()}>
<p><i className="arrow-down"></i></p>
</div>
<div className="timeline-info">
<p>{info_lang}</p>
<p>{date0} - {date1}</p>
</div>
</div>
<div className="timeline-content">
<div id="timeline" className="timeline" />
</div>
</div>
);
}
}
export default Timeline;

235
src/components/Toolbar.jsx Normal file
View File

@@ -0,0 +1,235 @@
import '../scss/main.scss';
import React from 'react';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import Search from './Search.jsx';
import TagListPanel from './TagListPanel.jsx';
import Icon from './Icon.jsx';
import copy from '../js/data/copy.json';
// NB: i think this entire component can actually be part of a future feature...
class Toolbar extends React.Component {
constructor(props) {
super(props);
this.state = {
tab: -1
};
}
toggleTab(tabIndex) {
if ( this.state.tab === tabIndex ) {
this.setState({ tab: -1 });
} else {
this.setState({ tab: tabIndex });
}
}
openCabinet() {
this.props.actions.openCabinet();
}
resetAllFilters() {
this.props.actions.resetAllFilters();
}
toggleInfoPopup() {
this.props.actions.toggleInfoPopup();
}
toggleLanguage() {
this.props.actions.toggleLanguage();
}
toggleMapViews(layer) {
const isLayerInView = !this.props.viewFilters[layer];
const newViews = {};
newViews[layer] = isLayerInView;
const views = Object.assign({}, this.props.viewFilters, newViews);
this.props.actions.updateFilters({ views });
}
renderMapActions() {
const isViewLayer = this.props.viewFilters;
const routeClass = (isViewLayer.routes) ? 'action-button active disabled' : 'action-button disabled'
const sitesClass = (isViewLayer.sites) ? 'action-button active disabled' : 'action-button disabled';
const coeventsClass = (isViewLayer.coevents) ? 'action-button active disabled' : 'action-button disabled';
if (this.props.isView2d) {
return (
<div className="bottom-action-block">
<button
className={routeClass}
onClick={() => this.toggleMapViews('routes')}
>
<svg x="0px" y="0px" width="30px" height="20px" viewBox="0 0 30 20" enableBackground="new 0 0 30 20">
<path d="M0.806,13.646h7.619c2.762,0,3-0.238,3-3v-0.414c0-2.762,0.301-3,3.246-3h14.523"/>
<polyline points="16.671,9.228 19.103,7.233 16.671,5.237 "/>
</svg>
</button>
<button
className={sitesClass}
onClick={() => this.toggleMapViews('sites')}
>
<svg x="0px" y="0px" width="30px" height="20px" viewBox="0 0 30 20" enableBackground="new 0 0 30 20">
<path d="M24.615,6.793H5.385c-2.761,0-3,0.239-3,3v0.414
c0,2.762,0.239,3,3,3h7.621l1.996,2.432l1.996-2.432h7.618c2.762,0,3-0.238,3-3V9.793C27.615,7.032,27.377,6.793,24.615,6.793z"/>
</svg>
</button>
<button
className={coeventsClass}
onClick={() => this.toggleMapViews('coevents')}
>
<svg className="coevents" x="0px" y="0px" width="30px" height="20px" viewBox="0 0 30 20" enableBackground="new 0 0 30 20">
<polygon stroke-linejoin="round" stroke-miterlimit="10" points="19.178,20 10.823,20 10.473,14.081
10,13.396 10,6.084 20,6.084 20,13.396 19.445,14.021 "/>
<rect className="no-fill" x="11.4" y="7.867" width="7.2" height="3.35"/>
<line stroke-linejoin="round" stroke-miterlimit="10" x1="12.125" y1="1" x2="12.125" y2="5.35"/>
<rect x="11.4" y="4.271" width="1.496" height="1.079"/>
<rect x="17.104" y="4.271" width="1.496" height="1.079"/>
</svg>
</button>
</div>
);
}
return (<div/>)
}
renderBottomActions() {
return (
<div className="bottom-actions">
{this.renderMapActions()}
<div className="bottom-action-block">
<button className="action-button tiny default" onClick={() => { /*this.toggleLanguage()*/}}>
{(this.props.language === 'es-MX') ? 'ES' : 'EN' }
</button>
<button className="action-button info tiny default" onClick={() => {/*this.toggleInfoPopup()*/}}>
i
</button>
<button className="action-button tiny" onClick={() => this.resetAllFilters()}>
<svg className="reset" x="0px" y="0px" width="25px" height="25px" viewBox="7.5 7.5 25 25" enableBackground="new 7.5 7.5 25 25">
<path stroke-width="2" stroke-miterlimit="10" d="M28.822,16.386c1.354,3.219,0.898,7.064-1.5,9.924
c-3.419,4.073-9.49,4.604-13.562,1.186c-4.073-3.417-4.604-9.49-1.187-13.562c1.987-2.368,4.874-3.54,7.74-3.433" />
<polygon points="26.137,12.748 27.621,19.464 28.9,16.741 31.898,16.503" />
</svg>
</button>
</div>
</div>
);
}
renderPanelHeader() {
return (
<div className="panel-header" onClick={() => this.toggleTab(-1)}>
<div className="caret"></div>
</div>
);
}
renderToolbarTab(tabNum, key) {
const isActive = (tabNum === this.state.tab);
//let caption_lang = copy[this.props.language].toolbar.tabs[tabNum];
let classes = (isActive) ? 'toolbar-tab active' : 'toolbar-tab';
return (
<div className={classes} onClick={() => { this.toggleTab(tabNum); }}>
{/*<Icon iconType={key} />*/}
<div className="tab-caption">{key}</div>
</div>
);
}
renderToolbarTagRoot() {
if (this.props.features.USE_TAGS &&
this.props.tags.children) {
const roots = Object.values(this.props.tags.children);
return roots.map((root, idx) => {
return this.renderToolbarTab(idx, root.key);
})
}
return '';
}
renderToolbarTabs() {
const title = copy[this.props.language].toolbar.title;
return (
<div className="toolbar">
<div className="toolbar-header" onClick={() => this.openCabinet()}><p>{title}</p></div>
<div className="toolbar-tabs">
{/*this.renderToolbarTab(0, 'search')*/}
{this.renderToolbarTagRoot()}
</div>
{/* {this.renderBottomActions()} */}
</div>
)
}
renderTagListPanel(tagType) {
const panels_lang = copy[this.props.language].toolbar.panels;
const title = (panels_lang[tagType]) ? panels_lang[tagType].title : tagType;
const overview = (panels_lang[tagType]) ? panels_lang[tagType].overview : '';
return (
<TagListPanel
tags={this.props.tags}
categories={this.props.categories}
tagFilters={this.props.tagFilters}
categoryFilters={this.props.categoryFilters}
filter={this.props.filter}
title={title}
overview={overview}
language={this.props.language}
tagType={tagType}
/>
);
}
renderSearch() {
if (this.props.features.USE_SEARCH) {
return (
<TabPanel>
<Search
language={this.props.language}
tags={this.props.tags}
categories={this.props.categories}
tagFilters={this.props.tagFilters}
categoryFilters={this.props.categoryFilters}
filter={this.props.filter}
/>
</TabPanel>
)
}
}
renderToolbarTagList() {
if (this.props.features.USE_TAGS &&
this.props.tags.children) {
const roots = Object.values(this.props.tags.children);
return roots.map((root, idx) => {
return (
<TabPanel>
{this.renderTagListPanel(root.key)}
</TabPanel>
)
})
}
return '';
}
render() {
let classes = (this.state.tab !== -1) ? 'toolbar-panels' : 'toolbar-panels folded';
return (
<div id="toolbar-wrapper" className="toolbar-wrapper">
{this.renderToolbarTabs()}
<div className={classes}>
{this.renderPanelHeader()}
<Tabs selectedIndex={this.state.tab}>
{this.renderToolbarTagList()}
</Tabs>
</div>
</div>
);
}
}
export default Toolbar;

61
src/components/View2D.jsx Normal file
View File

@@ -0,0 +1,61 @@
import '../scss/main.scss';
import React from 'react';
import Map from '../js/map/map.js';
import { areEqual } from '../js/data/utilities.js';
class View2D extends React.Component {
constructor(props) {
super(props);
}
componentDidMount() {
const domain = {
locations: this.props.locations,
sites: this.props.sites,
categoryGroups: this.props.categoryGroups
}
const app = {
views: this.props.views,
selected: this.props.selected,
highlighted: this.props.highlighted,
getCategoryGroup: this.props.getCategoryGroup,
getCategoryGroupColor: this.props.getCategoryGroupColor,
mapAnchor: this.props.mapAnchor
}
const ui = {
style: this.props.uiStyle,
dom: this.props.dom
}
this.map = new Map(app, ui, this.props.select);
this.map.update(domain, app);
}
componentWillReceiveProps(nextProps) {
const domain = {
locations: nextProps.locations,
sites: nextProps.sites,
categoryGroups: nextProps.categoryGroups
}
const app = {
views: nextProps.views,
selected: nextProps.selected,
highlighted: nextProps.highlighted,
getCategoryGroup: nextProps.getCategoryGroup,
getCategoryGroupColor: nextProps.getCategoryGroupColor,
mapAnchor: this.props.mapAnchor
}
this.map.update(domain, app);
}
render() {
return (
<div className='map-wrapper'>
<div id="map" />
</div>
);
}
}
export default View2D;

View File

@@ -0,0 +1,36 @@
import '../scss/main.scss';
import React from 'react';
import View2D from './View2D.jsx';
class Viewport extends React.Component {
constructor(props) {
super(props);
}
render() {
if( this.props.isView2d ) {
return (
<View2D
locations={this.props.locations}
sites={this.props.sites}
categoryGroups={this.props.categoryGroups}
views={this.props.views}
selected={this.props.selected}
highlighted={this.props.highlighted}
mapAnchor={this.props.mapAnchor}
uiStyle={this.props.uiStyle}
dom={this.props.dom}
select={this.props.select}
highlight={this.props.highlight}
getCategoryGroupColor={category => this.props.getCategoryGroupColor(category)}
getCategoryGroup={category => this.props.getCategoryGroup(category)}
/>
);
}
}
}
export default Viewport;

12
src/index.jsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store/index.js';
import App from './components/App.jsx';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('explore-app')
);

144
src/js/data/copy.json Normal file
View File

@@ -0,0 +1,144 @@
{
"es-MX": {
"loading": "Cargando...",
"legend": {
"view2d": {
"paragraphs": [
"Seleccionando una serie de filtros verá aparecer eventos en el mapa y en la línea del tiempo.",
"Cada evento estará coloreado según la persona que dio el testimonio del evento."
],
"colors": [
{ "class": "category_group00", "label": "Category Group 00" },
{ "class": "category_group01", "label": "Category Group 01" },
{ "class": "category_group02", "label": "Category Group 02" },
{ "class": "category_group03", "label": "Category Group 03" },
{ "class": "other", "label": "Other categories" }
]
}
},
"toolbar": {
"title": "TITLE",
"panels": {
"mentions": {
"title": "Personas",
"overview": "Seleccionar los nombres de personas mostrará eventos en los que esta persona o organización ha sido mencionada, incluyendo el propio testimonio. Entre paréntesis encontrará el número de menciones. Ej. (34)."
},
"categories": {
"title": "Testimonios",
"overview": "Seleccionar el nombre de una persona mostrará los eventos descritos por su testimonio. Entre paréntesis encontrará el número de eventos descritos. Ej. (34)."
},
"search": {
"title": "Directorio de etiquetas",
"placeholder": "Búsqueda"
}
}
},
"timeline": {
"zooms": [
"3 años",
"3 meses",
"3 días",
"12 horas",
"2 horas",
"30 min",
"10 min"
],
"labels_title": "Testimonios",
"labels": [
"Testimony Group 00",
"Testimony Group 01",
"Testimony Group 02",
"Testimony Group 03",
"Other categories"
],
"info": "Viendo eventos ocurridos entre"
},
"cardstack": {
"header": "eventos seleccionados",
"unknown_location": "Localización desconocida",
"timestamp": "Día y hora",
"estimated": "aproximado",
"location": "Localización",
"incident_type": "Tipo de acción",
"description": "Hechos",
"people": "Personas en el evento",
"source": "Fuente",
"category": "Según el testimonio de",
"communication": "Comunicación",
"transmitter": "Transmisor",
"receiver": "Receptor",
"warning": "(!) HECHOS CUESTIONADOS"
}
},
"en-US": {
"loading": "Loading...",
"legend": {
"view2d": {
"paragraphs": [
"Selecting a series of tags, you will be able to explore events on the map of Iguala and on the timeline.",
"Each event is colored according the person that gave category of the event."
],
"colors": [
{ "class": "category_group00", "label": "Category Group 00" },
{ "class": "category_group01", "label": "Category Group 01" },
{ "class": "category_group02", "label": "Category Group 02" },
{ "class": "category_group03", "label": "Category Group 03" },
{ "class": "other", "label": "Other categories" }
]
}
},
"toolbar": {
"title": "TITLE",
"panels": {
"mentions": {
"title": "Mentions",
"overview": "Selecting the names of people/organisation will show events in which these have been mentioned in their own testistimony and by others. The number in the parentheses shows how many events contain a mention of a person or organisation, e.g. (34)"
},
"categories": {
"title": "Testimonies",
"overview": "Selecting the name of a person will show the events only according to a persons category or category. The number in the parentheses show how many events are contained in each category, e.g. (34)."
},
"search": {
"title": "Directory of tags",
"placeholder": "Search"
}
}
},
"timeline": {
"zooms": [
"3 years",
"3 months",
"3 days",
"12 hours",
"2 hours",
"30 min",
"10 min"
],
"labels_title": "Testimonies",
"labels": [
"Testimony Group 00",
"Testimony Group 01",
"Testimony Group 02",
"Testimony Group 03",
"Other"
],
"info": "Seeing events occurred between"
},
"cardstack": {
"header": "selected events",
"timestamp": "Day and time",
"unknown_location": "Unknown location",
"estimated": "estimated",
"location": "Localization",
"incident_type": "Type of action",
"description": "Summary of facts",
"people": "People involved",
"source": "Source",
"category": "According to",
"communication": "Communication",
"transmitter": "Transmitter",
"receiver": "Receiver",
"warning": "(!) Highly questioned"
}
}
}

10
src/js/data/es-MX.json Normal file
View File

@@ -0,0 +1,10 @@
{
"dateTime": "%x, %X",
"date": "%d/%m/%Y",
"time": "%-I:%M:%S %p",
"periods": ["AM", "PM"],
"days": ["domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"],
"shortDays": ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"],
"months": ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
"shortMonths": ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"]
}

37
src/js/data/utilities.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* Get URI params to start with predefined set of
* https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
* @param {string} name: name of paramater to search
* @param {string} url: url passed as variable, defaults to window.location.href
*/
export function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, `\\$&`);
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
const results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
/**
* Compare two arrays of scalars
* @param {array} arr1: array of numbers
* @param {array} arr2: array of numbers
*/
export function areEqual(arr1, arr2) {
return ((arr1.length === arr2.length) && arr1.every((element, index) => {
return element === arr2[index];
}));
}
/**
* Return whether the variable is neither null nor undefined
* @param {object} variable
*/
export function isNotNullNorUndefined(variable) {
return (typeof variable !== 'undefined' && variable !== null);
}

393
src/js/map/map.js Normal file
View File

@@ -0,0 +1,393 @@
import {
areEqual,
isNotNullNorUndefined
} from '../data/utilities';
import hash from 'object-hash';
import 'leaflet-polylinedecorator';
export default function(newApp, ui, select) {
let svg, g, defs;
let categoryColorGroups = {};
const domain = {
locations: [],
categoryGroups: [],
sites: []
}
const app = {
selected: [],
highlighted: null,
views: Object.assign({}, newApp.views),
}
const getCategoryGroup = newApp.getCategoryGroup;
const getCategoryGroupColor = newApp.getCategoryGroupColor;
const groupColors = ui.style.groupColors;
// 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) {
s = L.tileLayer(
`http://a.tiles.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.png?access_token=${process.env.MAPBOX_TOKEN}`
);
} else {
s = L.tileLayer(`${process.env.SERVER_ROOT}/mapbox/{z}/{x}/{y}`);
}
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 lMap.latLngToLayerPoint(latLng);
}
function getSVGBoundaries() {
return {
topLeft: projectPoint(maxBoundaries[0]),
bottomRight: projectPoint(maxBoundaries[1])
}
}
function updateSVG() {
const boundaries = getSVGBoundaries();
const {
topLeft,
bottomRight
} = boundaries;
svg.attr('width', bottomRight.x - topLeft.x + 200)
.attr('height', bottomRight.y - topLeft.y + 200)
.style('left', `${topLeft.x - 100}px`)
.style('top', `${topLeft.y - 100}px`);
g.attr('transform', `translate(${-(topLeft.x - 100)},${-(topLeft.y - 100)})`);
g.selectAll('.location').attr('transform', (d) => {
const newPoint = projectPoint([+d.latitude, +d.longitude]);
return `translate(${newPoint.x},${newPoint.y})`;
});
const busLine = d3.line()
.x(d => lMap.latLngToLayerPoint(d).x)
.y(d => lMap.latLngToLayerPoint(d).y)
.curve(d3.curveMonotoneX);
}
lMap.on("zoom viewreset move", 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.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 eventsHere = {};
const categoryGroups = domain.categoryGroups;
categoryGroups.sort((a, b) => {
return (+a.slice(-2) > +b.slice(-2));
});
categoryGroups.forEach(group => {
eventsHere[group] = 0
});
location.events.forEach((event) => {
const group = getCategoryGroup(event.category);
eventsHere[group] += 1;
});
let i = 0;
const events = [];
while (i < categoryGroups.length) {
let eventsCount = eventsHere[categoryGroups[i]];
for (let j = i + 1; j < categoryGroups.length; j++) {
eventsCount += eventsHere[categoryGroups[j]];
}
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) => {
d.LatLng = new L.LatLng(+d.latitude, +d.longitude);
return `translate(${lMap.latLngToLayerPoint(d.LatLng).x},
${lMap.latLngToLayerPoint(d.LatLng).y})`;
})
.on('click', (location) => {
select(location.events);
});
const eventsDom = g.selectAll('.location').selectAll('.location-event-marker')
.data((d, i) => getLocationEventsDistribution(domain.locations[i]),
(d, i) => 'location-' + 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) => groupColors[domain.categoryGroups[i]])
.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);
}
}
// NB: is this a function to be removed for future features?
/**
* Creats a marker for an eventPoint along a path
* @param {Object} eventPoint: data for an evenPoint - time, loc, tags, etc
* @param {number} step: the portion of the entire path this event corresponds to
*/
function createPathEventMarker(eventPoint, step) {
const {
latitude,
longitude
} = getEventLocation(eventPoint);
const pathEventMarker = L.circleMarker(
[latitude, longitude], {
color: ui.colors.DARKGREY,
fill: ui.colors.DARKGREY,
weight: 2,
fillOpacity: 0.6,
radius: 10 * step,
},
);
// Add marker event handlers
pathEventMarker.bindPopup('');
pathEventMarker.on('popupopen', () => {
select([eventPoint]);
});
pathEventMarker.on('popupclose', () => {
select();
});
return pathEventMarker;
}
/**
* 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();
if (hash(domain) !== hash(newDomain)) {
domain.locations = newDomain.locations;
domain.categoryGroups = newDomain.categoryGroups;
domain.sites = newDomain.sites;
renderDomain();
}
if (hash(app) !== hash(newApp)) {
app.selected = newApp.selected;
app.highlighted = newApp.highlighted;
app.views = newApp.views;
renderSelectedAndHighlight();
}
}
/**
* Renders events on the map: takes data, and enters, updates and exits
*/
function renderDomain () {
renderSites();
renderEvents();
}
function renderSelectedAndHighlight () {
renderSelected();
renderHighlighted();
}
/**
* Expose only relevant functions
*/
return {
update
};
}

645
src/js/timeline/timeline.js Normal file
View File

@@ -0,0 +1,645 @@
/*
TIMELINE
Displays events over the course of the night
Allows brushing and selecting periods of time in it
TODO: is it possible to express this idiomatically as React?
*/
import {
areEqual
} from '../data/utilities';
import esLocale from '../data/es-MX.json';
import copy from '../data/copy.json';
export default function(app, ui) {
d3.timeFormatDefaultLocale(esLocale);
const formatterWithYear = ui.tools.formatterWithYear;
const parser = ui.tools.parser;
const zoomLevels = [{
label: '3 años',
duration: 1576800,
active: false
},
{
label: '3 meses',
duration: 129600,
active: false
},
{
label: '3 días',
duration: 4320,
active: true
},
{
label: '12 horas',
duration: 720,
active: false
},
{
label: '2 horas',
duration: 120,
active: false
},
{
label: '30 min',
duration: 30,
active: false
},
{
label: '10 min',
duration: 10,
active: false
},
];
let events = [];
let categoryGroups = [];
let selected = [];
let range = app.range;
const filter = app.filter;
const select = app.select;
const getCategoryLabel = app.getCategoryLabel;
const getCategoryGroupColor = app.getCategoryGroupColor;
const getCategoryGroup = app.getCategoryGroup;
// Play functions
window.playInterval;
let isPlaying = false;
const playDuration = 1000;
// Drag behavior
let dragPos0;
let transitionDuration = 500;
// Dimension of the client
const WIDTH_CONTROLS = 180;
const boundingClient = d3.select(`#${ui.dom.timeline}`).node().getBoundingClientRect();
let WIDTH = boundingClient.width - WIDTH_CONTROLS;
const HEIGHT = 140;
const markerRadius = 15;
// margin
// NB: is it possible to do this with SCSS?
// A: Maybe, although we are using it programmatically here for now
const mg = {
l: 120
};
/**
* Create scales
*/
const scale = {};
scale.x = d3.scaleTime()
.domain(range)
.range([mg.l, WIDTH]);
const groupStep = (106 - 30) / categoryGroups.length;
const groupYs = new Array(categoryGroups.length);
groupYs.map((g, i) => {
return 30 + i * groupStep;
});
scale.y = d3.scaleOrdinal()
.domain(categoryGroups)
.range(groupYs);
/**
* Initilize SVG elements and groups
*/
const dom = {};
dom.svg =
d3.select(`#${ui.dom.timeline}`)
.append('svg')
.attr('width', WIDTH)
.attr('height', HEIGHT);
dom.controls =
d3.select(`#${ui.dom.timeline}`)
.append('svg')
.attr('class', 'time-controls')
.attr('width', WIDTH_CONTROLS)
.attr('height', HEIGHT);
/*
* Axis group elements
*/
dom.axis = {};
dom.axis.x0 = dom.svg.append('g')
.attr('transform', `translate(0, 25)`)
.attr('class', 'axis xAxis');
dom.axis.x1 = dom.svg.append('g')
.attr('transform', `translate(0, 105)`)
.attr('class', 'axis axisHourText');
dom.axis.y = dom.svg.append('g')
.attr('transform', `translate(${WIDTH}, 0)`)
.attr('class', 'yAxis');
dom.axis.boundaries = dom.svg.selectAll('.axisBoundaries')
.data([0, 1])
.enter().append('line')
.attr('class', 'axisBoundaries');
dom.axis.label0 = dom.svg.append('text')
.attr('class', 'timeLabel0 timeLabel');
dom.axis.label1 = dom.svg.append('text')
.attr('class', 'timelabelF timeLabel');
/*
* Plottable elements
*/
dom.dataset = dom.svg.append('g');
dom.events = dom.dataset.append('g');
/*
* Time Controls
*/
dom.forward = dom.svg.append('g').attr('class', 'time-controls-inline');
dom.forward.append('circle');
dom.forward.append('path');
dom.backwards = dom.svg.append('g').attr('class', 'time-controls-inline');
dom.backwards.append('circle');
dom.backwards.append('path');
dom.playGroup = dom.controls.append('g');
dom.playGroup.append('circle');
dom.play = dom.playGroup.append('g');
dom.play.append('path');
dom.pause = dom.playGroup.append('g').style('opacity', 0);
dom.pause.append('rect');
dom.pause.append('rect');
dom.zooms = dom.controls.append('g');
dom.zooms.selectAll('.zoom-level-button')
.data(zoomLevels)
.enter().append('text')
.attr('class', 'zoom-level-button');
/*
* Initialize axis function and element group
*/
const axis = {};
axis.x0 =
d3.axisBottom(scale.x)
.ticks(10)
.tickPadding(5)
.tickSize(80)
.tickFormat(d3.timeFormat('%d %b'));
axis.x1 =
d3.axisBottom(scale.x)
.ticks(10)
.tickPadding(20)
.tickSize(0)
.tickFormat(d3.timeFormat('%H:%M'));
axis.y =
d3.axisLeft(scale.y)
.tickValues([]);
/*
* Setup drag behavior
*/
const drag =
d3.drag()
.on('start', () => {
d3.event.sourceEvent.stopPropagation();
dragPos0 = d3.event.x;
toggleTransition(false);
})
.on('drag', () => {
const drag0 = scale.x.invert(dragPos0).getTime();
const dragNow = scale.x.invert(d3.event.x).getTime();
const timeShift = (drag0 - dragNow) / 1000;
const newDomain0 = d3.timeSecond.offset(range[0], timeShift);
const newDomainF = d3.timeSecond.offset(range[1], timeShift);
scale.x.domain([newDomain0, newDomainF])
render();
})
.on('end', () => {
toggleTransition(true);
filter({
range: scale.x.domain()
});
});
/*
* SVG groups for marker
*/
dom.markers = dom.svg.append('g');
/**
* Adapt dimensions when resizing
*/
function getCurrentWidth() {
return d3.select(`#${ui.dom.timeline}`).node()
.getBoundingClientRect().width;
}
/**
* Resize timeline one window resice
*/
function addResizeListener() {
window.addEventListener('resize', () => {
if (d3.select(`#${ui.dom.timeline}`).node() !== null) {
WIDTH = getCurrentWidth() - WIDTH_CONTROLS;
dom.svg.attr('width', WIDTH);
scale.x.range([mg.l, WIDTH]);
axis.y.tickSize(WIDTH - mg.l);
dom.axis.y.attr('transform', `translate(${WIDTH}, 0)`)
render(null);
}
});
}
addResizeListener();
/**
* PLAY FUNCTIONALITY
*/
function stopBrushTransition() {
clearInterval(window.playInterval);
isPlaying = false;
dom.play.style('opacity', 1);
dom.pause.style('opacity', 0);
}
/**
* START PLAY SERIES OF TRANSITIONS
*/
function playBrushTransition() {
isPlaying = true;
dom.play.style('opacity', 0);
dom.pause.style('opacity', 1);
window.playInterval = setInterval(() => {
moveTime('forward');
}, playDuration);
}
/**
* Return which color event circle should be based on incident type
* @param {object} eventPoint data object
*/
function getEventPointFillColor(eventPoint) {
return getCategoryGroupColor(eventPoint.category);
}
/**
* Given an event, get all the filtered events that happen simultaneously
* @param {object} eventPoint: regular eventPoint data
*/
function getAllEventsAtOnce(eventPoint) {
const timestamp = eventPoint.timestamp;
const categoryGroup = getCategoryGroup(eventPoint.category);
return events.filter(event => {
return (event.timestamp === timestamp &&
categoryGroup === getCategoryGroup(event.category))
}).map(event => event.id);
}
/*
* Get y height of eventPoint, considering the ordinal Y scale
* @param {object} eventPoint: regular eventPoint data
*/
function getEventY(eventPoint) {
const yGroup = getCategoryGroup(eventPoint.category);
return scale.y(yGroup);
}
/*
* Get x position of eventPoint, considering the time scale
* @param {object} eventPoint: regular eventPoint data
*/
function getEventX(eventPoint) {
return scale.x(parser(eventPoint.timestamp));
}
function getTimeScaleExtent() {
return (scale.x.domain()[1].getTime() - scale.x.domain()[0].getTime()) / 60000;
}
/*
* Given a number of minutes, calculate the width based on current scale.x
* @param {number} minutes: number of minutes
*/
function getWidthOfTime(minutes) {
const allMins = getTimeScaleExtent();
return (minutes * WIDTH) / allMins;
}
function highlightZoomLevel(zoom) {
zoomLevels.forEach((level) => {
if (level.label === zoom.label) {
level.active = true;
} else {
level.active = false;
}
});
dom.zooms.selectAll('text')
.classed('active', level => level.active);
}
/**
* Apply zoom level to timeline
* @param {object} zoom: zoom level from zoomLevels
*/
function applyZoom(zoom) {
highlightZoomLevel(zoom);
const extent = getTimeScaleExtent();
const newCentralTime = d3.timeMinute.offset(scale.x.domain()[0], extent / 2);
const domain0 = d3.timeMinute.offset(newCentralTime, -zoom.duration / 2);
const domainF = d3.timeMinute.offset(newCentralTime, zoom.duration / 2);
scale.x.domain([domain0, domainF]);
filter({
range: scale.x.domain()
});
}
/**
* Shift time range by moving forward or backwards
* @param {Stirng} direction: 'forward' / 'backwards'
*/
function moveTime(direction) {
select();
const extent = getTimeScaleExtent();
const newCentralTime = d3.timeMinute.offset(scale.x.domain()[0], extent / 2);
// if forward
let domain0 = newCentralTime;
let domainF = d3.timeMinute.offset(newCentralTime, extent);
// if backwards
if (direction === 'backwards') {
domain0 = d3.timeMinute.offset(newCentralTime, -extent);
domainF = newCentralTime;
}
scale.x.domain([domain0, domainF]);
filter({
range: scale.x.domain()
});
}
function toggleTransition(isTransition) {
transitionDuration = (isTransition) ? 500 : 0;
}
/**
* Highlight event circle on hover
*/
function handleMouseOver() {
d3.select(this)
.attr('r', 7)
.classed('mouseover', true);
}
/**
* Unhighlight event when mouse out
*/
function handleMouseOut() {
d3.select(this)
.attr('r', 5)
.classed('mouseover', false);
}
/**
* It automatically sets brush timeline to a domain set by the params
*/
function updateTimeRange() {
scale.x.domain(range);
axis.x0.scale(scale.x);
axis.x1.scale(scale.x);
}
/**
* Display the current time range in the time label above the timeline
*/
function renderTimeLabels() {
dom.axis.label0
.attr('x', 5)
.attr('y', 15)
.text(formatterWithYear(range[0]));
dom.axis.label1
.attr('x', WIDTH - 5)
.attr('y', 15)
.text(formatterWithYear(range[1]))
.style('text-anchor', 'end');
}
/**
* Makes a circular rinig mark in one particular location at a time
* @param {object} eventPoint: object with eventPoint data (time, loc, tags)
*/
function renderHighlight() {
const markers = dom.markers
.selectAll('circle')
.data(selected);
markers
.enter()
.append('circle')
.attr('class', 'timeline-marker')
.merge(markers)
.attr('cy', eventPoint => getEventY(eventPoint))
.attr('cx', eventPoint => getEventX(eventPoint))
.attr('r', 10)
.style('opacity', .9);
markers.exit().remove();
}
/**
* Return event circles of different groups
*/
function renderEvents() {
const eventsDom = dom.events
.selectAll('.event')
.data(events, d => d.id);
eventsDom
.exit()
.remove();
eventsDom
.transition()
.duration(transitionDuration)
.attr('cx', eventPoint => getEventX(eventPoint));
eventsDom
.enter()
.append('circle')
.attr('class', 'event')
.attr('cx', eventPoint => getEventX(eventPoint))
.attr('cy', eventPoint => getEventY(eventPoint))
.style('fill', eventPoint => getEventPointFillColor(eventPoint))
.on('click', eventPoint => select(getAllEventsAtOnce(eventPoint)))
.on('mouseover', handleMouseOver)
.on('mouseout', handleMouseOut)
.transition()
.delay(300)
.duration(200)
.attr('r', 5);
}
/**
* Render axis on timeline and viewbox boundaries
*/
function renderAxis() {
dom.axis.x0
.call(drag);
dom.axis.x1
.call(drag);
dom.axis.x0
.transition()
.duration(transitionDuration)
.call(axis.x0);
dom.axis.x1
.transition()
.duration(transitionDuration)
.call(axis.x1);
axis.y.tickSize(WIDTH - mg.l);
dom.axis.y
.call(axis.y)
.call(drag);
dom.axis.boundaries
.attr('x1', (d, i) => scale.x.range()[i])
.attr('x2', (d, i) => scale.x.range()[i])
.attr('y1', 10)
.attr('y2', 20);
dom.axis.label1
.attr('x', scale.x.range()[1] - 5);
}
/**
* Render left and right time shifting controls
*/
function renderTimeControls() {
const zoomLabels = copy[app.language].timeline.zooms;
zoomLevels.forEach((level, i) => {
level.label = zoomLabels[i];
});
// These controls on timeline svg
dom.backwards.select('circle')
.attr('transform', `translate(${scale.x.range()[0] + 20}, 62)`)
.attr('r', 15);
dom.backwards.select('path')
.attr('d', d3.symbol().type(d3.symbolTriangle).size(80))
.attr('transform', `translate(${scale.x.range()[0] + 20}, 62)rotate(270)`);
dom.forward.select('circle')
.attr('transform', `translate(${scale.x.range()[1] - 20}, 62)`)
.attr('r', 15);
dom.forward.select('path')
.attr('d', d3.symbol().type(d3.symbolTriangle).size(80))
.attr('transform', `translate(${scale.x.range()[1] - 20}, 62)rotate(90)`);
// These controls on separate svg
dom.playGroup.select('circle')
.attr('transform', 'translate(135, 60)rotate(90)')
.attr('r', 25);
dom.play.select('path')
.attr('d', d3.symbol().type(d3.symbolTriangle).size(260))
.attr('transform', 'translate(135, 60)rotate(90)');
dom.pause.selectAll('rect')
.attr('transform', (d, i) => `translate(${125 + (i * 15)}, 47)`)
.attr('height', 25)
.attr('width', 5);
dom.zooms.selectAll('text')
.text(d => d.label)
.attr('x', 60)
.attr('y', (d, i) => (i * 15) + 20)
.classed('active', level => level.active);
dom.forward
.on('click', () => moveTime('forward'));
dom.backwards
.on('click', () => moveTime('backwards'));
dom.playGroup
.on('click', () => {
return (isPlaying) ? stopBrushTransition() : playBrushTransition();
});
dom.zooms.selectAll('text')
.on('click', zoom => applyZoom(zoom));
}
/**
* Updates data displayed by this timeline, but only render if necessary
* @param {Object} domain: Redux state domain subtree
* @param {Object} app: Redux state app subtree
*/
function updateAxis(domain) {
categoryGroups = domain.categoryGroups
const groupStep = (106 - 30) / categoryGroups.length;
let groupYs = Array.apply(null, Array(categoryGroups.length));
groupYs = groupYs.map((g, i) => {
return 30 + i * groupStep;
});
scale.y = d3.scaleOrdinal()
.domain(categoryGroups)
.range(groupYs);
axis.y =
d3.axisLeft(scale.y)
.tickValues(categoryGroups);
}
function update(domain, app) {
updateAxis(domain);
renderAxis();
events = domain.events;
range = app.range;
selected = app.selected.slice(0);
updateTimeRange();
}
function render() {
renderAxis();
renderTimeControls();
renderTimeLabels();
renderEvents();
renderHighlight();
}
return {
update,
render,
};
}

88
src/reducers/app.js Normal file
View File

@@ -0,0 +1,88 @@
import initial from '../store/initial.js';
import {
UPDATE_HIGHLIGHTED,
UPDATE_SELECTED,
UPDATE_FILTERS,
UPDATE_TIMERANGE,
RESET_ALLFILTERS,
TOGGLE_LANGUAGE,
FETCH_ERROR,
} from '../actions';
function updateHighlighted(appState, action) {
return Object.assign({}, appState, {
highlighted: action.highlighted
});
}
function updateSelected(appState, action) {
return Object.assign({}, appState, {
selected: action.selected
});
}
function updateFilters(appState, action) { // XXX
return Object.assign({}, appState, {
filters: Object.assign({}, appState.filters, action.filters)
});
}
function updateTimeRange(appState, action) { // XXX
return Object.assign({}, appState, {
filters: Object.assign({}, appState.filters, action.range),
});
}
function resetAllFilters(appState) { // XXX
return Object.assign({}, appState, {
filters: Object.assign({}, appState.filters, {
tags: [],
categories: [],
range: [
d3.timeParse("%Y-%m-%dT%H:%M:%S")("2014-09-25T12:00:00"),
d3.timeParse("%Y-%m-%dT%H:%M:%S")("2014-09-28T12:00:00")
],
}),
selected: [],
});
}
function toggleLanguage(appState, action) {
let otherLanguage = (appState.language === 'es-MX') ? 'en-US' : 'es-MX';
return Object.assign({}, appState, {
language: action.language || otherLanguage
});
}
function fetchError(state, action) {
return {
...state,
error: action.message,
notifications: [{ type: 'error', message: action.message }]
}
}
function app(appState = initial.app, action) {
switch (action.type) {
case UPDATE_HIGHLIGHTED:
return updateHighlighted(appState, action);
case UPDATE_SELECTED:
return updateSelected(appState, action);
case UPDATE_FILTERS:
return updateFilters(appState, action);
case UPDATE_TIMERANGE:
return updateTimeRange(appState, action);
case RESET_ALLFILTERS:
return resetAllFilters(appState, action);
case TOGGLE_LANGUAGE:
return toggleLanguage(appState, action);
case FETCH_ERROR:
return fetchError(appState, action);
default:
return appState;
}
}
export default app;

25
src/reducers/domain.js Normal file
View File

@@ -0,0 +1,25 @@
import initial from '../store/initial.js';
import {
UPDATE_DOMAIN,
} from '../actions';
import { parseDateTimes } from './utils/helpers.js';
import { validate } from './utils/validators.js';
function updateDomain(domainState, action) {
action.domain.events = parseDateTimes(action.domain.events);
return Object.assign({}, domainState, validate(action.domain));
}
function domain(domainState = initial.domain, action) {
switch (action.type) {
case UPDATE_DOMAIN:
return updateDomain(domainState, action);
default:
return domainState;
}
}
export default domain;

13
src/reducers/index.js Normal file
View File

@@ -0,0 +1,13 @@
import {
combineReducers
} from 'redux'
import domain from './domain.js'
import app from './app.js'
import ui from './ui.js'
export default combineReducers({
app,
domain,
ui
});;

View File

@@ -0,0 +1,12 @@
import Joi from 'joi';
const categorySchema = Joi.object().keys({
category: Joi.string().required(),
category_label: Joi.string().allow('').required(),
group: Joi.string(),
group_label: Joi.string(),
});
const optionalSchema = categorySchema.optionalKeys('group', 'group_label');
export default categorySchema;

View File

@@ -0,0 +1,20 @@
import Joi from 'joi';
const eventSchema = Joi.object().keys({
id: Joi.string().required(),
description: Joi.string().allow('').required(),
date: Joi.string().required(),
time: Joi.string().required(),
time_precision: Joi.string().allow(''),
location: Joi.string().allow('').required(),
latitude: Joi.string().required(),
longitude: Joi.string().required(),
type: Joi.string().allow(''),
category: Joi.string().required(),
source: Joi.string().allow(''),
tags: Joi.string().allow(''),
comments: Joi.string().allow(''),
timestamp: Joi.string().required(),
});
export default eventSchema;

View File

@@ -0,0 +1,11 @@
import Joi from 'joi';
const siteSchema = Joi.object().keys({
id: Joi.string().required(),
description: Joi.string().allow('').required(),
site: Joi.string().required(),
latitude: Joi.string().required(),
longitude: Joi.string().required()
});
export default siteSchema;

104
src/reducers/ui.js Normal file
View File

@@ -0,0 +1,104 @@
import initial from '../store/initial.js';
import {
TOGGLE_FETCHING_DOMAIN,
TOGGLE_FETCHING_EVENTS,
TOGGLE_VIEW,
TOGGLE_TIMELINE,
OPEN_CABINET,
CLOSE_CABINET,
TOGGLE_INFOPOPUP,
TOGGLE_NOTIFICATIONS
} from '../actions'
function toggleFetchingDomain(uiState, action) {
return Object.assign({}, uiState, {
flags: Object.assign({}, uiState.flags, {
isFetchingDomain: !uiState.flags.isFetchingDomain
})
});
}
function toggleFetchingEvents(uiState, action) {
return Object.assign({}, uiState, {
flags: Object.assign({}, uiState.flags, {
isFetchingEvents: !uiState.flags.isFetchingEvents
})
});
}
function toggleView(uiState, action) {
return Object.assign({}, uiState, {
flags: Object.assign({}, uiState.flags, {
isView2d: !uiState.flags.isView2d
})
});
}
function toggleTimeline(uiState, action) {
return Object.assign({}, uiState, {
flags: Object.assign({}, uiState.flags, {
isTimeline: !uiState.flags.isTimeline
})
});
}
function closeCabinet(uiState, action) {
return Object.assign({}, uiState, {
flags: Object.assign({}, uiState.flags, {
isCabinet: false
})
});
}
function openCabinet(uiState, action) {
return Object.assign({}, uiState, {
flags: Object.assign({}, uiState.flags, {
isCabinet: true
}),
components: Object.assign({}, uiState.components, {
cabinetFileTab: action.tabNum,
})
});
}
function toggleInfoPopup(uiState, action) {
return Object.assign({}, uiState, {
flags: Object.assign({}, uiState.flags, {
isInfopopup: !uiState.flags.isInfopopup
})
});
}
function toggleNotifications(uiState, action) {
return Object.assign({}, uiState, {
flags: Object.assign({}, uiState.flags, {
isNotification: !uiState.flags.isNotification
})
});
}
function ui(uiState = initial.ui, action) {
switch (action.type) {
case TOGGLE_FETCHING_DOMAIN:
return toggleFetchingDomain(uiState, action);
case TOGGLE_FETCHING_EVENTS:
return toggleFetchingEvents(uiState, action);
case TOGGLE_VIEW:
return toggleView(uiState, action);
case TOGGLE_TIMELINE:
return toggleTimeline(uiState, action);
case OPEN_CABINET:
return openCabinet(uiState, action);
case CLOSE_CABINET:
return closeCabinet(uiState, action);
case TOGGLE_INFOPOPUP:
return toggleInfoPopup(uiState, action);
case TOGGLE_NOTIFICATIONS:
return toggleNotifications(uiState, action);
default:
return uiState;
}
}
export default ui;

View File

@@ -0,0 +1,18 @@
export function parseDateTimes(arrayToParse) {
const parsedArray = [];
arrayToParse.forEach(item => {
let incoming_datetime = `${item.date}T00:00`;
if (item.time) incoming_datetime = `${item.date}T${item.time}`;
const parser = d3.timeParse(process.env.INCOMING_DATETIME_FORMAT);
item.timestamp = d3.timeFormat("%Y-%m-%dT%H:%M:%S")(parser(incoming_datetime));
parsedArray.push(item);
});
return parsedArray;
}
export function capitalize(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}

View File

@@ -0,0 +1,75 @@
import Joi from 'joi';
import eventSchema from '../schema/eventSchema.js';
import categorySchema from '../schema/categorySchema.js';
import siteSchema from '../schema/siteSchema.js';
import { capitalize } from './helpers.js';
/*
* Create an error notification object
* Types: ['error', 'warning', 'good', 'neural']
*/
function makeError(type, id, message) {
return {
type: 'error',
id,
message: `${type} ${id}: ${message}`
}
}
/*
* Validate domain schema
*/
export function validate(domain) {
const sanitizedDomain = {
events: [],
categories: [],
sites: [],
notifications: domain.notifications,
tags: domain.tags
}
const discardedDomain = {
events: [],
categories: [],
sites: []
}
function validateItem(item, domainClass, schema) {
const result = Joi.validate(item, schema);
if (result.error !== null) {
const id = item.id || '-';
const domainStr = capitalize(domainClass);
const error = makeError(domainStr, id, result.error.message);
discardedDomain[domainClass].push(Object.assign(item, { error }));
} else {
sanitizedDomain[domainClass].push(item);
}
}
domain.events.forEach(event => {
validateItem(event, 'events', eventSchema);
});
domain.categories.forEach(category => {
validateItem(category, 'categories', categorySchema);
});
domain.sites.forEach(site => {
validateItem(site, 'sites', siteSchema);
});
// Message the number of failed items
Object.keys(discardedDomain).forEach(disc => {
const len = discardedDomain[disc].length;
if (len) {
sanitizedDomain.notifications.push({
message: `${len} invalid ${disc} not displayed.`,
items: discardedDomain[disc],
type: 'error'
});
}
})
return sanitizedDomain;
}

21
src/scss/_animations.scss Normal file
View File

@@ -0,0 +1,21 @@
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fallFadeIn {
from {
transform: translate(0, -40px);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}

114
src/scss/_burger.scss Normal file
View File

@@ -0,0 +1,114 @@
// Burger transition
.side-menu-burg {
position: absolute;
overflow: hidden;
float: right;
margin: 0;
padding: 0;
width: 20px;
height: 20px;
appearance: none;
box-shadow: none;
border-radius: none;
border: none;
cursor: pointer;
background: none;
&.hidden {
display: none;
}
span {
display: block;
position: absolute;
top: 9px;
left: 0px;
right: 0px;
height: 2px;
background: $offwhite;
border-radius: 4px;
}
span::before,
span::after {
position: absolute;
display: block;
left: 0;
width: 100%;
height: 2px;
background: $offwhite;
border-radius: 4px;
content: "";
transition-duration: 0.2s, 0.2s;
transition-delay: 0.2s, 0s;
}
span::before {
transition-property: top, transform;
top: -8px;
}
span::after {
transition-property: bottom, transform;
bottom: -8px;
}
&:hover {
span::before {
top: -6px;
}
span::after {
bottom: -6px;
}
}
&.is-active {
span {
background: $midwhite;
transform: rotate(45deg);
transition-delay: 0s, 0.2s;
}
span::before,
span::after {
background: $midwhite;
transition-delay: 0s, 0.2s;
}
span::before {
top: 0;
transform: rotate(0deg);
-webkit-transform: rotate(0deg);
}
span::after {
bottom: 0;
transform: rotate(-90deg);
-webkit-transform: rotate(-90deg);
}
&:hover {
span,
span::before,
span::after {
transition: 0.2s ease;
background: $offwhite;
}
}
&.over-white:hover {
span,
span:before,
span:after {
transition: 0.2s ease;
background: $darkgrey;
}
}
}
}
.side-menu-burg:focus {
outline: none;
}

15
src/scss/_colors.scss Normal file
View File

@@ -0,0 +1,15 @@
$offwhite: #efefef;
$midwhite: #a0a0a0;
$darkwhite: darken($midwhite, 15%);
$yellow: #ffd800;
$red: rgb(233, 0, 19);
$green: rgb(61, 241, 79);
$midgrey: rgb(44, 44, 44);
$darkgrey: #232323;
$black: #000000;
$category_group00: #FF0000;
$category_group01: #226b22;
$category_group02: #671f6f;
$category_group03: #0000bf;
$category_group04: #d3ce2a;

9
src/scss/_fonts.scss Normal file
View File

@@ -0,0 +1,9 @@
$xsmall: 10px;//0.7em;
$small: 11px;//0.9em;
$normal: 12px;//1em;
$large: 14px;//1.1em;
$xlarge: 16px;//1.2em;
$xxlarge: 20px;//1.4em;
$xxxlarge: 32px;
$title: 36px;
$cover-title: 68px;

16
src/scss/_icons.scss Normal file
View File

@@ -0,0 +1,16 @@
.icon {
display: inline-block;
width: 32px;
height: 1em;
stroke-width: 0;
stroke: $offwhite;
fill: $offwhite;
transform: scale(1.4);
cursor: pointer;
&:hover {
transition: 0.2s ease;
stroke: $yellow;
fill: $yellow;
}
}

12
src/scss/_levels.scss Normal file
View File

@@ -0,0 +1,12 @@
/* z-index levels */
$final-level: 10000;
$loading-overlay: 1000;
$overheader: 100;
$header: 10;
$map-overlay2: 4;
$map-overlay: 2;
$map: 1;
$scene: 1;
$hidden: -1;
$timeline: 3;
$timeslider: 3;

44
src/scss/_video.scss Normal file
View File

@@ -0,0 +1,44 @@
.fullscreen-bg {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
z-index: -100;
}
.fullscreen-bg__video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
-webkit-filter: contrast(70%) brightness(70%) grayscale(30%); /* Webkit */
filter: gray; /* IE6-9 */
filter: contrast(70%) brightness(70%) grayscale(30%) /* W3C */
}
@media (min-aspect-ratio: 16/9) {
.fullscreen-bg__video {
height: 300%;
top: -100%;
}
}
@media (max-aspect-ratio: 16/9) {
.fullscreen-bg__video {
width: 300%;
left: -100%;
}
}
@media (max-width: 767px) {
.fullscreen-bg {
background: url('/static/archive/img/city.jpg') center center / cover no-repeat;
}
.fullscreen-bg__video {
display: none;
}
}

827
src/scss/cabinet.scss Normal file
View File

@@ -0,0 +1,827 @@
@import 'icons';
@import 'video';
.cabinet-wrapper {
overflow: auto;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: table;
table-layout: fixed;
color: $offwhite;
background-color: $black;
font-family: 'Merriweather', serif;
transition: opacity 0.5s ease 0.1s, z-index 0.1s ease 0s;
opacity: 1;
z-index: $final-level;
&.hidden {
transition: opacity 0.5s ease 0s, z-index 0.1s ease 0.5s;
opacity: 0;
z-index: $hidden;
}
&.show {
z-index: $final-level;
}
.top-action {
position: fixed;
top: 20px;
right: 20px;
z-index: 1;
button {
font-family: 'Lato';
font-size: $xlarge;
text-align: left;
text-transform: uppercase;
background: none;
border: none;
color: $offwhite;
outline: none;
cursor: pointer;
transition: 0.2s ease;
letter-spacing: 0.1em;
.label {
height: 28px;
float: left;
line-height: 28px;
padding-left: 10px;
}
svg {
float: left;
path, circle, polygon,
polyline, line {
stroke-width: 2px;
transition: 0.2s ease;
stroke: $offwhite;
fill: none;
stroke-linecap: round;
}
}
&:hover {
transition: 0.2s ease;
letter-spacing: 0.15em;
}
}
}
.cabinet-header {
position: fixed;
top: 0px;
width: 100%;
padding: 20px 0;
background: $black;
transition: 1s ease;
text-transform: uppercase;
font-family: 'Merriweather', serif;
.header-title {
width: 280px;
margin: 0 auto;
cursor: pointer;
p {
font-size: $normal;
margin: 0;
width: 100%;
text-align: center;
transition: 2s ease;
letter-spacing: normal;
}
p:first-child {
font-size: 8px;
}
&:hover {
p {
transition: 2s ease;
letter-spacing: 0.1em;
}
}
}
}
.share-sm {
position: fixed;
bottom: 20px;
right: 20px;
}
.side-menu-file-cabinet {
position: fixed;
top: 20px;
left: 20px;
z-index: 1;
}
.logo-fa {
background: url('/static/archive/img/logo-fa-square.png');
margin: 0px 0 30px 20px;
height: 30px;
width: 30px;
background-size: 30px;
animation-name: fadeIn;
animation-duration: 3s;
position: fixed;
top: 20px;
cursor: pointer;
}
&.cabinet-wrapper-files {
display: block;
background: $black;
}
&.cabinet-wrapper-cover {
background-color: $black;
background-repeat:no-repeat;
-webkit-background-size:cover;
-moz-background-size:cover;
-o-background-size:cover;
background-size:cover;
background-position:center;
}
}
.cabinet-cover {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
vertical-align: middle;
width: 100%;
bottom: 0;
top: 0;
animation-name: fadeIn;
animation-duration: 0.5s;
.content {
display: inline-block;
width: 90%;
max-width: 800px;
box-sizing: border-box;
padding: 30px;
border-radius: 2px;
overflow: auto;
}
.cabinet-cover-header {
width: 100%;
margin-bottom: 20px;
animation-name: fallFadeIn;
animation-duration: 3s;
.main-title {
.title {
font-size: $cover-title;
text-transform: uppercase;
}
margin-top: 0;
margin-bottom: 0;
}
.untertitle {
font-size: $xxlarge;
text-transform: none;
letter-spacing: 0.1em;
}
}
.cabinet-cover-content {
width: 100%;
animation-name: fadeIn;
animation-duration: 3s;
.tile-row {
position: relative;
padding: 10px;
box-sizing: border-box;
.tile {
position: relative;
display: inline-block;
width: calc(50% - 20px);
height: 90px;
line-height: 90px;
box-sizing: border-box;
background: none;
border: 1px solid $offwhite;
font-family: 'Lato', Helvetica, sans-serif;
font-size: $xxlarge;
text-transform: uppercase;
cursor: pointer;
transition: 1s ease;
letter-spacing: 0.1em;
span {
z-index: 1;
}
&:before {
position: absolute;
content: " ";
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
z-index: 0;
transition: 1s ease;
background-color: rgba($black, 0.15);
}
&:hover {
transition: 1s ease;
letter-spacing: 0.15em;
&:before{
transition: 1s ease;
background-color: rgba($black, 0.3);
}
}
&:first-child {
margin-right: 20px;
}
&.full {
width: 100%;
margin-right: 0;
}
& * {
position: relative;
}
.tile-content {
margin: 0 auto;
padding: 30px 0;
width: 200px;
.label {
height: 28px;
float: left;
line-height: 28px;
padding-left: 10px;
}
svg {
float: left;
path, circle, polygon,
polyline, line {
stroke-width: 2px;
transition: 0.2s ease;
stroke: $offwhite;
fill: none;
stroke-linecap: round;
}
}
}
}
}
}
}
.secondary-action {
width: 90px;
margin: 0 auto;
letter-spacing: 0.1em;
text-transform: uppercase;
font-family: 'Lato';
font-size: $small;
padding: 20px 0;
text-align: center;
transition: 0.2s ease;
border-bottom: 2px solid rgba($offwhite, 0);
cursor: pointer;
&:hover {
transition: 0.2s ease;
letter-spacing: 0.15em;
border-bottom: 2px solid $yellow;
}
}
.cabinet-files {
animation-name: fadeIn;
animation-duration: 0.5s;
&.file-tab-list-off {
ul.cabinet-file-tab-list {
transition: 0.2s ease;
left: -300px;
}
}
ul.cabinet-file-tab-list {
width: 300px;
position: fixed;
float: left;
top: 0;
bottom: 0;
margin: 0;
padding-top: 160px;
border-right: 1px solid $darkgrey;
background: $black;
transition: 0.2s ease;
left: 0;
z-index: 1;
li.cabinet-file-tab {
width: calc(100% - 40px);
text-align: left;
border: 0;
margin-left: 20px;
margin-right: 20px;
box-sizing: border-box;
font-family: 'Merriweather', serif;
font-size: $large;
font-weight: 100;
border-bottom: 1px solid $darkgrey;
transition: 0.2s ease;
color: $offwhite;
&:hover {
transition: 0.2s ease;
color: $yellow;
}
&.react-tabs__tab--selected {
color: $yellow;
&:after {
content: '';
}
}
}
.action {
text-align: left;
border: 0;
box-sizing: border-box;
button {
margin: 20px;
width: calc(100% - 40px);
letter-spacing: 0.1em;
text-transform: uppercase;
font-family: 'Lato';
font-size: $large;
text-align: left;
padding-left: 0;
}
&.secondary-action button {
padding: 20px;
border: 1px solid $midwhite;
text-align: center;
&:hover {
transition: 0.2s ease;
border-color: $offwhite;
background: rgba($offwhite, 0.1);
}
}
}
.language-toggle {
width: 100%;
position: absolute;
bottom: 0;
z-index: 10;
}
}
.react-tabs__tab-panel {
z-index: 0;
}
.cabinet-file-content {
width: 90%;
max-width: 800px;
box-sizing: border-box;
padding: 140px 30px 60px 30px;
border-radius: 2px;
overflow: auto;
margin: 0 auto;
text-align: left;
animation-name: fadeIn;
animation-duration: 1.5s;
h1 {
font-size: $title;
text-align: center;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 50px;
}
.title-separator {
margin: 0 auto;
width: 100px;
border-bottom: 4px solid $yellow;
margin-bottom: 50px;
}
h2 {
letter-spacing: normal;
text-transform: capitalize;
margin-top: 40px;
}
.cabinet-body-text {
padding: 10px 0;
.section {
margin-bottom: 40px;
}
p {
margin: 20px 0;
font-size: $xlarge;
font-weight: 100;
line-height: 24px;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
.highpoint {
font-size: $xxxlarge;
margin: 60px 0;
border-left: 4px solid $yellow;
padding-left: 20px;
}
img {
width: 100%;
}
.actions {
width: 100%;
display: inline-block;
}
.primary-action {
button {
font-size: $large;
height: 140px;
line-height: 140px;
width: 100%;
padding: 0;
border: 1px solid $offwhite;
background-size: 100%;
color: $offwhite;
cursor: pointer;
outline: none;
font-family: 'Lato', Helvetica, sans-serif;
text-transform: uppercase;
margin-bottom: 10px;
transition: 0.2s ease;
letter-spacing: 0.1em;
&:after {
content: '';
}
&:hover {
transition: 0.2s ease;
letter-spacing: 0.15em;
}
}
&.only-on-mobile {
display: none;
button:after {
content: '';
}
}
&.ja {
button { background-image: url("/static/archive/img/scene01.jpg"); }
}
&.pj {
button { background-image: url("/static/archive/img/scene02.jpg"); }
}
&.st {
button { background-image: url("/static/archive/img/scene03.jpg"); }
}
}
}
}
.actions {
min-width: 200px;
margin: 0 auto;
.action {
display: inline-block;
float: left;
}
button {
font-size: $large;
display: block;
width: 100%;
}
.primary-action {
width: 100%;
animation-name: fadeIn;
animation-duration: 1s;
&:first-child {
margin-right: 5%;
}
button {
height: 120px;
margin: 20px auto;
letter-spacing: 0.1em;
text-transform: uppercase;
font-family: 'Lato';
}
&.mobile {
width: 100%;
}
}
.document-action {
width: 100%;
border: 0;
display: block;
margin: 0 auto;
clear: both;
button {
display: inline-block;
text-align: center;
width: 100%;
height: 40px;
line-height: 40px;
margin: 10px auto;
padding: 0;
letter-spacing: 0.1em;
text-transform: uppercase;
border: 0;
outline: none;
border-bottom: 4px solid rgba($yellow, 0);
background: none;
a {
text-align: center;
box-sizing: border-box;
padding-bottom: 5px;
cursor: pointer;
text-decoration: none;
color: $offwhite;
&:after {
content: '';
}
&:hover {
transition: 0.2s ease;
border-bottom: 4px solid rgba($yellow, 1);
color: $yellow;
}
}
}
}
&:last-child {
margin-bottom: 40px;
}
}
}
/**
*
* VIDEO ON LANDING
*/
.fullscreen {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 2;
background: $black;
}
.video {
display: block;
left: 0px;
overflow: hidden;
padding-bottom: 56.25%; /* 56.25% = 16:9. set ratio */
position: absolute;
top: 50%;
width: 100%;
-webkit-transform-origin: 50% 0;
transform-origin: 50% 0;
-webkit-transform: translateY(-50%);
transform: translateY(-50%);
}
.video .wrapper {
display: block;
height: 300%;
left: 0px;
overflow: hidden;
position: absolute;
top: 50%;
width: 100%;
-webkit-transform: translateY(-50%);
transform: translateY(-50%);
}
.video iframe {
display: block;
height: 100%;
width: 100%;
}
/*
* VIDEO ON SITE
*/
.video-responsive{
overflow:hidden;
padding-bottom:56.25%;
position:relative;
height:0;
}
.video-responsive iframe{
left:0;
top:0;
height:100%;
width:100%;
position:absolute;
}
/*
* Responsiveness
*
*/
@media (max-width: 780px) {
.cabinet-files .cabinet-file-content .cabinet-body-text .primary-action {
&.not-on-mobile {
display: none;
}
&.only-on-mobile {
display: block;
}
}
.cabinet-cover {
.content {
padding: 0;
}
.cabinet-cover-header {
margin-bottom: 40px;
.main-title {
.title {
font-size: 36px;
}
}
.untertitle {
font-size: $normal;
}
}
.cabinet-cover-content .tile-row {
padding: 0;
.tile {
width: 60%;
height: 48px;
line-height: 48px;
margin: 0 20%;
margin-bottom: 10px;
font-size: $normal;
&:first-child {
margin-right: 20%;
}
&.tile00 {
display: none;
}
}
}
}
.cabinet-files {
ul.cabinet-file-tab-list {
width: 100%;
li.cabinet-file-tab {
text-align: center;
font-size: $normal;
height: 48px;
line-height: 48px;
}
}
&.file-tab-list-off {
ul.cabinet-file-tab-list {
transition: 0.2s ease;
left: -100%;
}
}
.cabinet-file-content {
width: 100%;
margin: 0;
padding: 100px 15px 40px 15px;
box-sizing: border-box;
overflow: auto;
.cabinet-body-text {
padding: 10px 0;
font-size: $xlarge;
p {
margin: 20px 0;
}
img {
width: 100%;
}
}
}
}
.top-action.not-on-mobile {
display: none;
}
}
/* Tablets with very short heights */
@media (max-height: 768px) and (min-width: 780px) {
.cabinet-cover .content {
max-width: 640px;
}
.cabinet-cover .cabinet-cover-header {
margin-bottom: 20px;
.main-title .title {
font-size: 48px;
}
}
.cabinet-cover .cabinet-cover-content .tile-row .tile {
height: 50px;
line-height: 50px;
font-size: $xlarge;
.tile-content {
padding: 10px 0;
}
}
}
@media (min-width: 1350px) {
.cabinet-files {
animation-name: fadeIn;
animation-duration: 0.5s;
&.file-tab-list-off {
ul.cabinet-file-tab-list {
left: 0;
}
}
}
.side-menu-file-cabinet {
display: none;
}
}
/* ----------- Non-Retina Screens ----------- */
@media screen
and (min-device-width: 1200px)
and (max-device-width: 1600px)
and (-webkit-min-device-pixel-ratio: 1) {
}
/* ----------- Retina Screens ----------- */
@media screen
and (min-device-width: 1200px)
and (max-device-width: 1600px)
and (-webkit-min-device-pixel-ratio: 2)
and (min-resolution: 192dpi) {
}

190
src/scss/card.scss Normal file
View File

@@ -0,0 +1,190 @@
.event-card {
box-sizing: border-box;
margin: 1px 0 0 0;
padding: 15px;
border: 1px solid rgba(0, 0, 0, 0);
border-radius: 3px;
transition: 0.2 ease;
background: $offwhite;
color: $darkgrey;
box-shadow: 0 19px 19px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
font-size: $large;
line-height: $xxlarge;
transition: 0.2s ease;
height: auto;
&:hover {
border: 1px solid $yellow;
}
.card-bottomhalf {
transition: 0.4s ease;
height: auto;
&.folded {
transition: 0.4s ease;
height: 0;
overflow: hidden;
}
}
h4 {
text-transform: normal;
margin-bottom: 0;
&:first-child {
margin-top: 0;
}
}
p {
margin: 0;
}
.event-card-section {
margin-bottom: 10px;
}
.card-toggle p {
text-align: center;
cursor: pointer;
.arrow-down {
display: inline-block;
transition: 0.2s ease;
border: solid $darkwhite;
border-width: 0 2px 2px 0;
padding: 3px;
transform: rotate(-135deg);
-webkit-transform: rotate(-135deg);
&.folded {
transition: 0.2s ease;
transform: rotate(45deg);
-webkit-transform: rotate(45deg);
}
}
&:hover .arrow-down {
transition: 0.2s ease;
border: solid $darkgrey;
border-width: 0 2px 2px 0;
}
}
.tags {
width: 100%;
margin: 5px 0;
text-align: left;
}
.warning {
background: $red;
color: white;
text-transform: uppercase;
width: 100%;
text-align: center;
}
.timestamp {
font-family: 'Lato', Helvetica, sans-serif;
margin-top: 0;
.estimated-timestamp {
color: $midwhite;
margin-left: 5px;
}
}
.category {
.color-category {
width: 12px;
height: 12px;
border-radius: 20px;
display: inline-block;
margin: 0px 5px 0 0;
&.category_group00 { background: $category_group00; }
&.category_group01 { background: $category_group01; }
&.category_group02 { background: $category_group02; }
&.category_group03 { background: $category_group03; }
&.category_group04 { background: $category_group04; }
}
}
.event-type {
margin: 0 0 10px 0;
span {
display: inline-block;
margin: 0 5px 2px 0;
color: $darkgrey;
&.flagged {
background: $red;
color: $offwhite;
padding: 0 5px;
}
}
}
.location {
font-family: 'Lato', Helvetica, sans-serif;
}
.summary {
overflow: auto;
margin-top: 0;
}
.tag {
display: inline-block;
margin: 0;
margin-right: 5px;
}
/*
https://github.com/tobiasahlin/SpinKit/blob/master/LICENSE
*/
.spinner {
width: 40px;
height: 40px;
position: relative;
margin: 20px auto;
}
.double-bounce1, .double-bounce2 {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: $darkgrey;
opacity: 0.6;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 3.0s infinite ease-in-out;
animation: sk-bounce 3.0s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
@-webkit-keyframes sk-bounce {
0%, 100% { -webkit-transform: scale(0.0) }
50% { -webkit-transform: scale(1.0) }
}
@keyframes sk-bounce {
0%, 100% {
transform: scale(0.0);
-webkit-transform: scale(0.0);
} 50% {
transform: scale(1.0);
-webkit-transform: scale(1.0);
}
}
}

92
src/scss/cardstack.scss Normal file
View File

@@ -0,0 +1,92 @@
@import 'burger';
@import 'card';
.card-stack {
position: absolute;
top: 10px;
right: 10px;
max-height: calc(100% - 208px);
height: auto;
overflow: auto;
box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
z-index: $header;
color: white;
-webkit-font-smoothing: antialiased;
&.full-height {
max-height: calc(100% - 20px);
}
.card-stack-header {
min-height: 38px;
line-height: 38px;
width: 360px;
box-sizing: border-box;
padding: 0 5px;
background: $black;
border-radius: 2px;
border: 1px solid $black;
font-size: $large;
transition: 0.2s ease;
text-align: left;
&:hover {
transition: 0.2s ease;
}
.header-copy {
margin: 0;
padding: 0 10px;
line-height: 20px;
text-align: right;
&.top {
padding-top: 10px;
}
&:last-child {
padding-bottom: 10px;
}
}
.side-menu-burg {
position: absolute;
left: 8px;
top: 9px;
span {
width: 20px;
}
}
}
.card-stack-content {
width: 360px;
ul {
padding: 0;
margin-top: 1px;
margin-bottom: 0;
}
.card-list {
height: auto;
}
}
&.folded {
.card-stack-header {
border: 0;
height: 0;
overflow: hidden;
}
.card-stack-content {
height: 0;
overflow: hidden;
}
}
}
li {
list-style-type: none;
}

137
src/scss/common.scss Normal file
View File

@@ -0,0 +1,137 @@
@import 'colors';
body {
margin: 0;
overflow: hidden;
background: $black;
a {
text-decoration: none;
&:hover {
color: $yellow;
}
}
}
h1 {
font-family: 'Merriweather', serif;
}
h2 {
text-transform: uppercase;
letter-spacing: 0.1em;
}
.login-wrapper {
margin-left: 20px;
color: white;
.login-title {
p.message {
color: $yellow;
}
}
form span {
width: 120px;
display: inline-block;
}
form input {
margin: 10px 0;
height: 30px;
box-sizing: border-box;
padding: 0 5px;
outline: none;
font-family: 'Lato', sans-serif;
&:focus {
border: 3px solid $yellow;
}
}
form button {
background: $black;
color: white;
width: 120px;
height: 30px;
border: 1px solid $offwhite;
text-transform: uppercase;
letter-spacing: 0.1em;
cursor: pointer;
outline: none;
margin-top: 10px;
margin-left: 320px;
&:hover,
&:focus {
transition: 0.2s ease;
border: 1px solid $yellow;
color: $yellow;
}
}
}
.page {
font-family: 'Lato', Helvetica sans-serif;
box-sizing: border-box;
height: 100%;
width: 100%;
::-moz-selection {
color: $black;
background: $yellow;
}
::selection {
color: $black;
background: $yellow;
}
}
.chart {
background: #000010;
}
.primary-action {
button {
font-size: 1.2em;
height: 40px;
line-height: 40px;
width: 200px;
padding: 0;
border: 1px solid $offwhite;
background: none;
color: $offwhite;
cursor: pointer;
outline: none;
&:hover {
transition: 0.2s ease;
color: $yellow;
border: 1px solid $yellow;
background: rgba(white, 0.1);
}
}
}
/*
Scrollbar
*/
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: none;
}
::-webkit-scrollbar-thumb {
border-radius: 3px;
background: $offwhite;
}
.hidden {
visibility: hidden;
}

62
src/scss/header.scss Normal file
View File

@@ -0,0 +1,62 @@
.header {
background: #000000;
position: fixed;
padding: 10px;
z-index: 10;
top: 10px;
right: 10px;
height: 40px;
width: 240px;
box-sizing: border-box;
text-overflow: ellipsis;
box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
cursor: pointer;
.header-title {
a {
font-family: 'Lato', Helvetica, serif;
color: darken($offwhite, 5%);
font-size: $xlarge;
letter-spacing: 0.1em;
float: left;
text-transform: uppercase;
}
p {
margin: 0;
}
}
.side-menu-burg {
right: 10px;
span,
span::before,
span::after {
background: $midwhite;
}
}
&:hover {
.side-menu-burg {
span {
transition: 0.2s ease;
background: $offwhite;
}
span::before {
transition: 0.2s ease;
top: -6px;
background: $offwhite;
}
span::after {
transition: 0.2s ease;
bottom: -6px;
background: $offwhite;
}
}
.header-title a {
transition: 0.2s ease;
color: $offwhite;
}
}
}

116
src/scss/infopopup.scss Normal file
View File

@@ -0,0 +1,116 @@
@import 'burger';
.infopopup {
width: 400px;
height: 400px;
box-shadow: 10px -10px 38px rgba(0, 0, 0, 0.3), 10px 15px 12px rgba(0, 0, 0, 0.22);
color: $darkgrey;
position: absolute;
background: $offwhite;
border-radius: 5px;
bottom: 20px;
left: 100px;
border: 3px solid $offwhite;
padding: 20px;
box-sizing: border-box;
font-family: 'Lato', 'Helvetica', sans-serif;
font-size: $large;
transition: opacity 0.5s ease 0.1s, z-index 0.1s ease 0s;
opacity: 1;
z-index: $overheader;
&.hidden {
transition: 0.5s ease;
opacity: 0;
}
.legend-section {
width: 300px;
padding-left: 60px;
height: 40px;
display: inline-block;
svg {
width: 100px;
float: left;
display: inline-block;
}
.legend-labels {
float: left;
display: inline-block;
width: calc(100% - 100px);
.label {
display: block;
font-size: $xsmall;
margin-top: 10px;
margin-left: 10px;
.color-category {
width: 8px;
height: 8px;
border-radius: 10px;
display: inline-block;
margin: 0px 5px 0 0;
&.category_group00 { background: $category_group00; }
&.category_group01 { background: $category_group01; }
&.category_group02 { background: $category_group02; }
&.category_group03 { background: $category_group03; }
&.category_group04 { background: $category_group04; }
}
}
}
&:first-child {
.legend-labels .label {
margin-top: 0;
}
}
}
.side-menu-burg {
position: absolute;
right: 8px;
top: 10px;
}
.legend-item {
display: block;
width: 100%;
display: inline-block;
margin-bottom: 3px;
padding-left: 80px;
.item-label {
line-height: 15px;
height: 15px;
font-size: $normal;
}
.color-marker {
display: inline-block;
width: 15px;
height: 15px;
float: left;
margin: 0 10px 0 0;
border-radius: 15px;
&.victims { background-color: #C90500; }
&.military { background-color: #319C31; }
&.nonstate { background-color: #AC28AC; }
&.state-police { background-color: #0000BF; }
&.iguala-municipal-police { background-color: #00558D; }
&.federal-police { background-color: #5756A2; }
&.huitzuco-municipal-police { background-color: #4ECAC1; }
&.cocula-municipal-police { background-color: #095959; }
&.ambulance { background-color: #ffffff; }
&.other { background-color: #D3CE2A; }
&.drivers { background-color: #822519; }
&.communications { background-color: #a6a6a6; }
&.GIEI { background-color: #ffffff; }
&.PGR { background-color: #000000; }
}
}
}

80
src/scss/loading.scss Normal file
View File

@@ -0,0 +1,80 @@
.loading-overlay {
font-family: 'Lato', Helvetica, sans-serif;
font-weight: 300;
width: 100%;
height: 100%;
position: absolute;
background: rgba(0,0,0,0.9);
transition: 0.4s ease;
z-index: $loading-overlay;
opacity: 1;
.loading-wrapper {
position: fixed;
left: 50%;
top: 40%;
text-align: center;
width: 100%;
margin: 0 0 0 -50%;
height: 100%;
opacity: 1;
span {
color: $offwhite;
letter-spacing: 0.1em;
text-transform: uppercase;
}
}
&.hidden {
transition: opacity 0.4s ease, z-index .1s 0.4s;
opacity: 0;
z-index: $hidden;
}
/*
https://github.com/tobiasahlin/SpinKit/blob/master/LICENSE
*/
.spinner {
width: 40px;
height: 40px;
position: relative;
margin: 20px auto;
}
.double-bounce1, .double-bounce2 {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: $offwhite;
opacity: 0.6;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 3.0s infinite ease-in-out;
animation: sk-bounce 3.0s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
@-webkit-keyframes sk-bounce {
0%, 100% { -webkit-transform: scale(0.0) }
50% { -webkit-transform: scale(1.0) }
}
@keyframes sk-bounce {
0%, 100% {
transform: scale(0.0);
-webkit-transform: scale(0.0);
} 50% {
transform: scale(1.0);
-webkit-transform: scale(1.0);
}
}
}

15
src/scss/main.scss Normal file
View File

@@ -0,0 +1,15 @@
@import 'colors';
@import 'fonts';
@import 'levels';
@import 'animations';
@import 'common';
@import 'loading';
@import 'header';
@import 'cardstack';
@import 'map';
@import 'timeline';
@import 'tag-filters';
@import 'toolbar';
@import 'infopopup';
@import 'notification';
@import 'scene';

223
src/scss/map.scss Normal file
View File

@@ -0,0 +1,223 @@
@import 'popup';
@-webkit-keyframes pulsate {
0% { opacity: 0.1; }
50% { opacity: 0.25; }
100% { opacity: 0.1; }
}
.map-wrapper {
position: fixed;
top: 0px;
bottom: 0px;
left: 110px;
right: 0;
.leaflet-container {
height: 100%;
img.leaflet-tile {
-webkit-filter: contrast(120%) brightness(115%) grayscale(95%); /* Webkit */
filter: gray; /* IE6-9 */
filter: contrast(120%) brightness(115%) grayscale(95%); /* W3C */
}
}
&.hidden {
z-index: $hidden;
}
&.show {
z-index: $map;
}
.event {
fill: $yellow;
cursor: pointer;
opacity: 0.45;
}
.link {
stroke: $midgrey;
fill: none;
stroke-width: 2;
stroke-dasharray: 2px 5px;
}
.site-label {
background: rgba($black,0.6);
color: #fff;
padding: 2px 7px;
font-weight: 500;
font-size: 11px;
font-family: 'Lato', Helvetica, sans-serif;
border: rgba($black,0.6);
letter-spacing: 0.05em;
&::before {
border-top-color: rgba($black,0.6);
}
}
}
/*
* Leaflet mapping controls
*/
.leaflet-touch .leaflet-bar {
.leaflet-control-zoom {
border: 0;
margin-left: 20px;
margin-top: 20px;
}
a.leaflet-control-zoom-in,
a.leaflet-control-zoom-out {
border: 0;
border-radius: 2px;
color: $yellow;
}
a.leaflet-control-zoom-in {
border-bottom: 1px solid $yellow;
}
}
/*
* Leaflet marker and popups
*/
.leaflet-svg {
display: block;
&.hide {
display: none;
}
}
.leaflet-popup {
display: none;
&.do-display {
display: block;
}
}
.leaflet-popup-content-wrapper {
border-radius: 3px;
background: $black;
.leaflet-popup-content {
color: white;
margin: 0;
padding: 3px 5px;
.event-card {
margin: 0;
}
}
}
.leaflet-popup-close-button {
display: none;
& + .leaflet-popup-content-wrapper .leaflet-popup-content {
padding-top: 3px;
}
}
.leaflet-popup-tip-container {
display: none;
}
.leaflet-pane > svg path.bus-route,
.leaflet-pane > svg path.district {
pointer-events: auto;
}
.eventLocationMarker {
fill: none;
stroke: $yellow;
stroke-width: 2;
}
/*
*
* Elements
*/
.location-event-marker {
stroke-width: 0;
transition: 0.2s ease;
fill-opacity: 0.8;
cursor: pointer;
&:hover {
transition: 0.2s ease;
fill-opacity: 1;
}
}
.coevent-marker {
fill-opacity: 0.1;
stroke-dasharray: 8px 4px;
stroke-width: 2px;
opacity: 1;
}
.coevent-path {
stroke-dasharray: 8px 4px;
stroke-width: 2;
}
.district-boundaries {
fill: $red;
fill-opacity: 0.3;
stroke-width: 2;
stroke: $red;
}
.path-polyline {
stroke: $darkgrey;
stroke-width: 2px;
}
.route-polyline {
transition: 0.2s ease;
stroke: $darkgrey;
&:hover {
transition: 0.2s ease;
stroke: $black;
}
}
.district-popup {
button {
height: 80px;
line-height: 80px;
width: 200px;
padding: 0;
border: none;
background: none;
background-size: 100%;
color: $offwhite;
cursor: pointer;
outline: none;
font-family: 'Lato', Helvetica, sans-serif;
text-transform: uppercase;
p {
font-size: $normal;
margin: 0;
transition: 0.2s ease;
letter-spacing: 0.1em;
&:first-child {
font-size: $xsmall;
}
}
&:hover {
p:last-child {
transition: 0.2s ease;
letter-spacing: 0.15em;
}
}
}
}

View File

@@ -0,0 +1,71 @@
@import 'burger';
.notification-wrapper {
top: 60px;
right: 60px;
width: 400px;
height: auto;
position: absolute;
display: flex;
flex-direction: column;
}
.notification {
width: 100%;
min-height: 40px;
box-shadow: 10px -10px 38px rgba(0, 0, 0, 0.3), 10px 15px 12px rgba(0, 0, 0, 0.22);
color: $darkgrey;
background: $offwhite;
border-radius: 5px;
border: 3px solid $offwhite;
padding: 20px;
margin-bottom: 10px;
box-sizing: border-box;
font-family: 'Lato', 'Helvetica', sans-serif;
font-size: $large;
transition: opacity 0.5s ease 0.1s, z-index 0.1s ease 0s;
opacity: 1;
z-index: $overheader;
cursor: pointer;
&:hover {
background: lighten($offwhite, 5%);
transition: background-color 0.4s;
}
&.hidden {
transition: 0.5s ease;
opacity: 0;
}
.side-menu-burg {
position: absolute;
right: 8px;
top: 10px;
}
.message {
display: inline-block;
&.error { color: red; }
&.warning { color: orange; }
&.good { color: green; }
&.neutral { color: $darkgrey; }
}
.details {
overflow: hidden;
display: flex;
flex-direction: column;
&.true {
height: auto;
transition: height 0.4s;
}
&.false {
height: 0;
transition: height 0.4s;
}
}
}

81
src/scss/popup.scss Normal file
View File

@@ -0,0 +1,81 @@
.popup {
box-sizing: border-box;
margin: 0;
padding: 15px;
border: 0;
opacity: 0;
border-radius: 2px;
transition: 0.2 ease;
background: rgba(0, 0, 0, 0.9);
transition: 0.4s ease;
box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
&:hover {
transition: 0.4s ease;
box-shadow: 0 29px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
}
.card-tophalf {
height: 100px;
.left {
float: left;
width: 120px;
padding-right: 5px;
box-sizing: border-box;
border-right: 1px dotted $midwhite;
}
.right {
float: left;
width: 225px;
padding-left: 5px;
height: 90px;
overflow: hidden;
}
}
.tag,
p.see-more {
cursor: pointer;
&:hover {
color: $yellow;
}
}
p {
margin: 5px 0 0 0;
}
.timestamp {
font-family: 'Lato', Helvetica, sans-serif;
text-transform: uppercase;
font-size: $xlarge;
margin-top: 0;
}
.location {
font-family: 'Lato', Helvetica, sans-serif;
font-size: $normal;
color: $offwhite;
}
.estimated-timestamp {
margin-top: 3px;
margin-left: 3px;
font-size: $xsmall;
color: $midwhite;
text-transform: lowercase;
}
.summary {
max-height: 200px;
text-overflow: ellipsis;
overflow: scroll;
font-weight: 500;
}
.source {
text-align: right;
}
}

177
src/scss/scene.scss Normal file
View File

@@ -0,0 +1,177 @@
.scene-wrapper {
#container {
position: absolute;
top: 0;
left: 0;
right: 0;
display: block;
}
&.hidden {
z-index: $hidden;
}
&.show {
z-index: $scene;
}
}
#loadingText {
text-align: center;
position:relative;
margin: 0 auto;
margin-top: 20px;
clear:left;
height:auto;
z-index: 0;
color: rgba( 255, 255, 255, 255 );
font-size: $normal;
font-weight: 700;
color: $offwhite;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.back-to-map {
button {
position: fixed;
top: 20px;
left: 130px;
height: 20px;
width: 250px;
text-align: left;
background: none;
padding: 0;
margin-bottom: 10px;
border: none;
background-size: 100%;
line-height: 20px;
color: $offwhite;
cursor: pointer;
outline: none;
font-family: 'Lato', Helvetica, sans-serif;
text-transform: uppercase;
transition: 0.2s ease;
letter-spacing: 0.1em;
&:hover {
transition: 0.2s ease;
letter-spacing: 0.15em;
}
}
}
/*
KEYFRAME INFO
*/
.keyframe-info {
position: fixed;
top: 60px;
left: 130px;
height: auto;
width: 270px;
box-sizing: border-box;
padding: 10px;
max-height: calc(100% - 250px);
overflow: auto;
box-shadow: 0 19px 38px rgba($black, 0.3), 0 15px 12px rgba($black, 0.22);
background: $black;
border: 1px solid $midgrey;
color: $offwhite;
font-family: 'Merriweather', 'Georgia', serif;
h3, h6 {
text-align: center;
}
h3 {
font-size: $large;
}
p {
font-family: 'Lato', 'Helvetica', sans-serif;
font-size: $normal;
line-height: 1.4em;
}
.actions {
width: 100%;
.action {
width: calc(50% - 5px);
height: 40px;
box-sizing: border-box;
line-height: 40px;
font-family: 'Lato', 'Helvetica', sans-serif;
text-align: center;
display: inline-block;
&:not(.disabled) {
&:hover {
cursor: pointer;
transition: 0.2s ease;
color: $yellow;
}
}
&.disabled {
color: $midgrey;
cursor: normal;
}
&:first-child {
margin-right: 10px;
}
}
}
}
/*
DAT GUI
*/
.dg .a {
margin-right: 20px;
margin-top: 20px;
}
.dg .cr.number {
border: none;
background: none;
input[type=text] {
display: none;
}
}
.dg.main .close-button {
display: none;
}
.dg .c {
width: 66%;
.slider {
width: 100%;
border-radius: 10px;
height: 12px;
.slider-fg {
border-radius: 10px;
}
&:hover {
.slider-fg {
background: $offwhite;
}
}
}
}
.dg .c .slider-fg {
background: $midwhite;
}
.dg .property-name {
width: 33%;
font-family: 'Lato', Helvetica, sans-serif;
text-transform: uppercase;
letter-spacing: 0.1em;
line-height: 20px;
color: white;
}

47
src/scss/tabs.scss Normal file
View File

@@ -0,0 +1,47 @@
.react-tabs {
padding-top: 0;
box-sizing: border-box;
height: 100%;
[role=tablist] {
padding: 0;
}
[role=tab] {
font-family: 'Lato', Helvetica, sans-serif;
font-size: $xlarge;
width: 33%;
background: none;
color: $midwhite;
outline: none;
float: left;
cursor: pointer;
text-align: center;
height: 40px;
line-height: 40px;
border-bottom: 1px solid rgba(255, 255, 255, 0.4);
list-style-type: none;
box-sizing: border-box;
&:hover {
color: $offwhite;
}
}
[role=tab][aria-selected=true] {
font-weight: 700;
border-radius: 0;
border: 0;
color: $offwhite;
border: 1px solid;
box-sizing: border-box;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.4);
border-bottom: 0;
}
.react-innertabpanel {
box-sizing: border-box;
padding-top: 20px;
}
}

85
src/scss/tag-filters.scss Normal file
View File

@@ -0,0 +1,85 @@
.applied-tagFilters {
position: fixed;
top: 135px;
right: 5px;
max-width: 260px;
background: $black;
color: $offwhite;
padding: 10px;
font-size: $small;
z-index: $map-overlay;
.caption {
font-size: $small;
display: block;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.tag-chip-group {
display: inline-block;
width: 100%;
max-height: 150px;
background: $midgrey;
border-radius: 5px;
margin-top: 5px;
padding: 0 5px 5px 5px;
box-sizing: border-box;
overflow: auto;
h3 {
margin: 5px 0;
}
}
.applied-tagFilter-chip {
width: auto;
border-radius: 10px;
border: 0;
color: $black;
min-height: 16px;
line-height: 16px;
margin-top: 5px;
margin-left: 5px;
padding: 1px 5px;
transition: 0.2s ease;
background: $yellow;
float: right;
&:hover {
transition: 0.2s ease;
background: lighten($yellow, 15%);
}
&:first-child {
margin-top: 5px;
}
.detail {
cursor: pointer;
max-width: 350px;
margin-right: 15px;
}
svg {
display: inline-block;
position: relative;
float: right;
margin-top: -14px;
height: 12px;
width: 12px;
user-select: none;
cursor: pointer;
color: rgba($black, 0.25);
fill: rgba($black, 0.25);
transition: all 450ms cubic-bezier(0.23, 1, 0.32, 1) 0ms;
&:hover {
color: rgba($black, 0.6);
fill: rgba($black, 0.6);
transition: all 450ms cubic-bezier(0.23, 1, 0.32, 1) 0ms;
}
}
}
}

288
src/scss/timeline.scss Normal file
View File

@@ -0,0 +1,288 @@
.timeline-wrapper {
position: fixed;
box-sizing: border-box;
left: 110px;
right: 0px;
height: 170px;
background: rgba($black, 0.8);
box-shadow: 0 -10px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
color: white;
transition: 0.2s ease;
bottom: 0px;
z-index: $timeline;
&.folded {
transition: 0.2s ease;
bottom: -170px;
.timeline-header .timeline-toggle p .arrow-down {
transform: translate(0, 5px)rotate(-135deg);
-webkit-transform: translate(0, 5px)rotate(-135deg);
}
}
.timeline-header {
height: 0px;
width: 100%;
font-size: $large;
font-weight: 700;
.timeline-toggle {
position: absolute;
margin: 0 auto;
width: 100%;
text-align: center;
p {
width: 60px;
height: 25px;
margin: 0 auto;
background: rgba($black, 0.8);
margin-top: -25px;
cursor: pointer;
&:hover {
.arrow-down {
transition: 0.2s ease;
border-right: 2px solid $offwhite;
border-bottom: 2px solid $offwhite;
}
}
}
.arrow-down {
display: inline-block;
padding: 3px;
transition: 0.2s ease;
transform: rotate(45deg);
-webkit-transform: rotate(45deg);
border-right: 2px solid $midwhite;
border-bottom: 2px solid $midwhite;
}
}
.timeline-info {
position: absolute;
margin-top: -70px;
margin-left: 10px;
background: rgba($black, 0.8);
padding: 10px;
min-height: 20px;
p {
margin: 0;
height: 20px;
text-transform: uppercase;
letter-spacing: 0.1em;
&:first-child {
text-transform: none;
font-size: $normal;
letter-spacing: 0.05;
}
}
}
}
.timeline-content {
height: 160px;
padding-top: 20px;
.timeline-labels {
padding-top: 2px;
padding-left: 20px;
margin-right: 0px;
border-right: 1px solid $midgrey;
width: 175px;
height: 180px;
float: left;
text-align: left;
box-sizing: border-box;
.timeline-label-title {
font-size: $normal;
font-weight: 700;
fill: $offwhite;
letter-spacing: 0.1em;
height: 20px;
text-transform: uppercase;
}
.timeline-label {
font-size: $small;
line-height: 16px;
color: $offwhite;
text-align: right;
padding-right: 10px;
letter-spacing: 0.05em;
}
}
.timeLabel {
font-size: $normal;
fill: $midwhite;
letter-spacing: 0.05em;
}
.timeline {
/*width: calc(100% - 200px);*/
width: calc(100% - 40px);
margin-left: 20px;
box-sizing: border-box;
float: left;
svg {
display: inline-block;
float: left;
}
.domain {
opacity: 0;
}
.tick {
cursor: -webkit-grab;
cursor: -moz-grab;
line {
stroke: rgb(199, 199, 199);
shape-rendering: crispEdges;
opacity: 0.6;
}
text {
font-family: 'Lato', Helvetica, sans-serif;
fill: $midwhite;
text-transform: capitalize;
}
}
.xAxis {
line {
stroke-dasharray: 1px 4px;
}
}
.yAxis {
.tick line {
stroke-width: 15px;
cursor: -webkit-grab;
cursor: -moz-grab;
}
}
.axisBoundaries {
stroke: $offwhite;
stroke-width: 1;
stroke-dasharray: 1px 4px;
}
.event {
cursor: pointer;
opacity: .7;
&.mouseover {
opacity: 1;
}
}
.timeline-marker {
fill: none;
stroke: $offwhite;
stroke-width: 2;
stroke-dasharray: 5px 2px;
}
.coevent {
opacity: .7;
cursor: pointer;
}
.time-controls path,
.time-controls rect {
cursor: pointer;
transition: 0.2s ease;
fill: $midwhite;
&:hover path,
&:hover path, {
transition: 0.2s ease;
fill: $offwhite;
}
}
.time-controls-inline path {
cursor: pointer;
fill: $offwhite;
}
.time-controls circle,
.time-controls-inline circle {
fill: $midwhite;
fill-opacity: 0.01;
cursor: pointer;
stroke: $midwhite;
stroke-width: 1;
}
.time-controls-inline circle { stroke: none; }
.time-controls g,
.time-controls-inline {
&:hover {
cursor: pointer;
circle {
transition: 0.2s ease;
fill-opacity: 0.2;
fill: $offwhite;
}
path,
rect {
transition: 0.2s ease;
fill: $offwhite;
}
}
}
.zoom-level-button {
font-size: $xsmall;
cursor: pointer;
text-anchor: middle;
letter-spacing: 0.05em;
transition: 0.2s ease;
fill: $midwhite;
&:hover,
&.active {
transition: 0.2s ease;
fill: $offwhite;
}
}
}
}
}
/*
* Slider
* https://bl.ocks.org/mbostock/6452972
*/
.track,
.track-overlay {
stroke-linecap: round;
}
.track {
stroke: $offwhite;
stroke-opacity: 1;
stroke-width: 1px;
}
.track-overlay {
pointer-events: stroke;
stroke-width: 15px;
stroke: transparent;
cursor: pointer;
}
.handle {
fill: $offwhite;
}

660
src/scss/toolbar.scss Normal file
View File

@@ -0,0 +1,660 @@
@import 'burger';
@import 'tabs';
.toolbar-wrapper {
position: fixed;
top: 0px;
left: 0px;
bottom: 0px;
z-index: $header;
background: $midgrey;
.toolbar {
position: relative;
width: 110px;
height: 100%;
padding: 20px 0px;
margin: 0;
box-sizing: border-box;
color: $offwhite;
background: $darkgrey;
text-align: center;
font-size: $normal;
font-weight: 100;
transition: 0.2s ease;
z-index: $header;
.toolbar-header {
margin: 0 15px 10px 15px;
padding: 10px 0 25px 0;
transition: 0.2s ease;
border-bottom: 2px solid $midwhite;
text-transform: uppercase;
font-family: 'Merriweather', serif;
cursor: pointer;
p {
font-size: $normal;
margin: 0;
}
p:first-child {
font-size: $xsmall;
}
&:hover {
transition: 0.2s ease;
border-bottom: 2px solid $offwhite;
}
}
.toolbar-tabs {
padding: 0;
}
.bottom-actions {
position: absolute;
width: 110px;
bottom: 10px;
box-sizing: border-box;
.bottom-action-block {
display: block;
&:last-child {
padding-left: 8px;
}
}
.action-button {
width: 60px;
height: 25px;
border-radius: 30px;
background: none;
margin: 0 auto;
margin-top: 10px;
display: block;
outline: none;
font-family: 'Lato';
font-size: $xsmall;
cursor: pointer;
transition: 0.2s ease;
border: 1px solid $midwhite;
color: $midwhite;
&.tiny {
height: 30px;
width: 30px;
display: inline-block;
float: left;
margin-right: 2px;
&:last-child {
margin-right: 0;
}
}
&:hover:not(.disabled) {
transition: 0.2s ease;
border: 1px solid $offwhite;
color: $offwhite;
svg path { stroke: $offwhite; }
svg polyline { stroke: $offwhite; }
svg polygon { fill: $offwhite; }
}
svg {
&.reset {
margin-left: -4px;
margin-top: -1px;
-webkit-transform: scale(0.9);
-moz-transform: translate(-2px,1px)scale(0.9);
transform: scale(0.9);
}
path, polyline {
fill: none;
stroke: $midwhite;
stroke-width: 2px;
}
polygon {
fill: $midwhite;
}
&.coevents {
margin: 0;
-webkit-transform: scale(0.9);
transform: scale(1.2);
path { stroke-width: 2px; }
rect {
fill: $midwhite;
&.no-fill { fill: $darkgrey; }
}
line {
stroke-width: 1px;
stroke: $midwhite;
}
}
}
&.info {
font-size: $xxlarge;
bottom: 120px;
}
&.disabled {
cursor: default;
}
&.active {
border: 1px solid $offwhite;
color: $offwhite;
svg path { stroke: $offwhite; }
svg polyline { stroke: $offwhite; }
svg polygon { fill: $offwhite; }
}
}
}
}
.toolbar-tab {
display: inline-block;
height: 60px;
width: 110px;
padding: 10px 0 5px 0;
font-weight: 400;
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
transition: 0.2s ease;
color: $midwhite;
svg {
transform: scale(0.7);
path, circle, polygon,
polyline, line {
stroke-width: 2px;
transition: 0.2s ease;
stroke: $midwhite;
fill: none;
stroke-linecap: round;
}
&.scenes {
path {
transition: 0.2s ease;
fill: $midwhite;
stroke: none;
}
}
}
.tab-caption {
display: block;
text-align: center;
font-size: $xsmall;
margin-top: -2px;
letter-spacing: 0.05em;
}
&.active {
background: $black;
}
&:hover,
&.active {
transition: 0.2s ease;
color: $offwhite;
svg {
path, circle, polygon,
polyline, line {
transition: 0.2s ease;
stroke: $offwhite;
}
&.scenes {
path {
transition: 0.2s ease;
fill: $offwhite;
stroke: none;
}
}
}
}
}
}
.toolbar-panels {
width: 440px;
top: 15px;
bottom: 0;
box-sizing: border-box;
padding: 30px 10px 10px 30px;
font-size: $normal;
font-family: 'Lato', Helvetica, sans-serif;
background: $black;
color: $offwhite;
position: fixed;
transition: 0.2s ease;
left: 110px;
box-shadow: 10px -10px 38px rgba(0, 0, 0, 0.3), 10px 15px 12px rgba(0, 0, 0, 0.22);
h2 {
font-family: 'Merriweather', 'Georgia', 'serif';
font-size: $large;
text-transform: none;
letter-spacing: normal;
}
p {
font-size: $normal;
font-family: 'Merriweather', 'Georgia', 'serif';
line-height: 1.4em;
};
.panel-header {
display: inline-block;
width: 36px;
float: right;
margin-left: 20px;
margin-right: -45px;
height: 36px;
padding-top: 5px;
box-sizing: border-box;
margin-top: 10px;
border-radius: 3px;
background: $black;
padding: 8px 6px;
cursor: pointer;
.caret {
position: relative;
transform: translate(8px, 5px)rotate(45deg);
width: 8px;
height: 8px;
transition: 0.2s ease;
border-left: 2px solid $midwhite;
border-bottom: 2px solid $midwhite;
}
&:hover {
.caret {
transition: 0.2s ease;
border-left: 2px solid $offwhite;
border-bottom: 2px solid $offwhite;
}
}
}
.people-tab {
width: 50%;
font-family: 'Lato', Helvetica, sans-serif;
font-size: $normal;
text-transform: uppercase;
letter-spacing: 0.1em;
svg {
transform: translate(-2px,0)scale(0.6);
&:hover {
transition: 0.2s ease;
stroke: $offwhite;
}
}
&.react-tabs__tab--selected {
svg circle,
svg path {
stroke: $offwhite;
}
}
svg circle,
svg path {
transition: 0.2s ease;
fill: none;
stroke: $midwhite;
stroke-width: 3;
}
}
.react-tabs__tab-list {
height: 40px;
overflow: hidden;
}
.react-tabs__tab-panel {
margin-top: 0px;
}
.react-tabs__tab-panel--selected {
height: calc(100% - 40px);
overflow-y: auto;
margin-top: 0;
.react-tabs__tab-panel--selected {
padding-top: 20px;
box-sizing: border-box;
}
}
.react-tabs .react-innertabpanel {
padding-top: 0;
}
ul {
margin: 0;
padding-left: 0;
height: auto;
transition: 0.2s ease;
height: calc(100% - 310px);
}
&.folded {
transition: 0.2s ease;
left: -440px;
ul {
height: 0;
margin: 0;
}
}
input {
width: 100%;
border: 1px solid;
height: 60px;
color: $offwhite;
background: none;
outline: none;
box-sizing: border-box;
margin: 20px 0;
padding: 5px 10px;
font-size: 18px;
font-family: 'Lato', sans-serif;
letter-spacing: 0.1em;
transition: 0.2s ease;
border-color: $midwhite;
text-align: center;
&:focus {
transition: 0.2s ease;
border-color: $offwhite;
}
}
.item {
width: 100%;
height: 36px;
line-height: 36px;
background: none;
font-family: 'Lato', Helvetica, sans-serif;
font-size: $large;
button {
height: 36px;
border: 1px transparent;
background: none;
cursor: pointer;
color: $offwhite;
outline: none;
transition: 0.2s ease;
padding: 0 10px;
text-align: left;
float: left;
.checkbox {
display: inline-block;
width: 12px;
height: 12px;
border: 1px solid $offwhite;
box-sizing: border-box;
background: none;
float: left;
}
}
span {
width: calc(100% - 40px);
display: inline-block;
height: 36px;
line-height: 36px;
float: left;
font-size: $normal;
font-family: 'Merriweather', 'Georgia', 'serif';
color: $midwhite;
}
&:hover {
span {
color: $offwhite;
}
}
&.active {
span {
color: $offwhite;
}
.checkbox {
background: $offwhite;
}
}
}
.arrow {
display: inline-block;
width: 10px;
height: 10px;
line-height: 10px;
padding: 10px;
float: left;
cursor: pointer;
color: $offwhite;
transition: 0.4s ease;
transform: rotate(0deg);
&:after {
content: '';
}
&.folded {
transition: 0.4s ease;
transform: rotate(-90deg);
}
}
.panel-action {
button {
font-size: 1.2em;
height: 140px;
line-height: 140px;
width: 100%;
padding: 0;
border: 1px solid $offwhite;
background-size: 100%;
color: $offwhite;
cursor: pointer;
outline: none;
font-family: 'Lato', Helvetica, sans-serif;
text-transform: uppercase;
margin-bottom: 10px;
transition: 0.2s ease;
letter-spacing: 0.1em;
&:hover {
transition: 0.2s ease;
letter-spacing: 0.15em;
}
}
&:first-child {
button { background-image: url("/static/archive/img/scene01.jpg"); }
}
&:nth-child(2n) {
button { background-image: url("/static/archive/img/scene02.jpg"); }
}
&:nth-child(3n) {
button { background-image: url("/static/archive/img/scene03.jpg"); }
}
&.back-to-map {
button { background-image: url("/static/archive/img/map.jpg"); }
}
}
}
.taggroup-wrapper {
margin-top: 30px;
z-index: 10;
border-bottom: none;
&:last-child {
margin-bottom: 0;
border-bottom: 1px solid rgba(white, 0);
}
&:hover {
transition: 0.1s ease;
}
.collapsible-item {
width: calc(100% - 32px);
float: left;
}
.taggroup-header {
width: 100%;
margin: 0;
font-size: $large;
h2::first-letter {
margin-top: 0;
}
}
.taggroup-content {
width: 100%;
display: inline-block;
padding-left: 10px;
box-sizing: border-box;
transition: 0.2s ease;
.tagsubgroup-wrapper {
border: none;
border-bottom: 1px solid rgba(white, 0.25);
&:first-letter {
text-transform: uppercase;
}
&:last-child {
border-bottom: 0;
}
.tagsubgroup-header {
cursor: pointer;
}
&.folded {
.tagsubgroup-content {
overflow: hidden;
padding: 0 10px;
transition: 0.2s ease;
height: 0;
border-top: 0;
}
}
.item {
overflow: auto;
min-height: 32px;
height: auto;
span {
height: auto;
}
}
}
.tag-filter {
outline: none;
border: 0;
background: none;
color: $offwhite;
margin-left: 20px;
width: calc(100% - 20px);
box-sizing: border-box;
padding: 0;
font-size: $normal;
font-weight: 400;
text-align: left;
cursor: pointer;
border: 1px solid $black;
border-bottom: 1px solid rgba(white, 0.25);
&:first-letter {
text-transform: uppercase;
}
&:last-child {
border-bottom: 1px solid rgba(white, 0);
}
}
}
&.folded {
.filter-list-content {
padding: 0 10px;
border-top: 0;
transition: 0.2s ease;
height: 0;
}
}
}
.search-content {
.item {
overflow: auto;
min-height: 32px;
height: auto;
border-bottom: 1px solid rgba(white, 0.25);
span {
height: auto;
}
}
}
.path-list {
margin-bottom: 10px;
.item {
border-bottom: 1px solid rgba(255, 255, 255, 0.25);
}
}
@media (max-height: 678px) {
.toolbar-wrapper {
.toolbar-tab {
height: 60px;
padding: 0;
.tab-caption {
transition: 0.2s ease;
opacity: 0;
}
&:hover {
.tab-caption {
transition: 0.2s ease;
opacity: 1;
}
}
}
.toolbar .bottom-actions {
.action-button {
margin-top: 5px;
}
}
}
}

33
src/scss/video.scss Normal file
View File

@@ -0,0 +1,33 @@
.video-wrapper {
z-index: 1;
position: relative;
width: 740px;
height: 420px;
transition: opacity 500ms;
background-color: black;
font-family: 'Lato', Helvetica, sans-serif;
overflow: hidden;
}
.video-js .vjs-big-play-button {
font-size: 3em;
line-height: 40px;
height: 40px;
width: 40px;
display: block;
position: absolute;
background: none;
top: 10px;
left: 10px;
padding: 0;
cursor: pointer;
opacity: 1;
border-radius: 20px;
transition: 0.2s ease;
border: 1px solid $midwhite;
&:hover {
transition: 0.2s ease;
border: 1px solid $offwhite;
}
}

134
src/selectors/index.js Normal file
View File

@@ -0,0 +1,134 @@
import {
createSelector
} from 'reselect'
// Input selectors
export const getEvents = state => state.domain.events;
export const getLocations = state => state.domain.locations;
export const getCategories = state => state.domain.categories;
export const getSites = (state) => {
if (process.env.features.USE_SITES) return state.domain.sites;
return [];
}
export const getTags = state => state.domain.tags;
export const getCategoriesFilter = state => state.app.filters.categories;
export const getTagsFilter = state => state.app.filters.tags;
export const getRangeFilter = state => state.app.filters.range;
// NB: should we stick with the default semantics and name these as selectors?
// e.g. 'selectEvents', 'selectCoevents'.
// Filter events
function isTaggedIn(event, tagFilters) {
if (event.tags) {
const tagsArray = event.tags.split(",");
const isTagged = tagsArray.some((tag) => {
return tagFilters.find((tagFilter) => {
return (tagFilter.key === tag && tagFilter.active);
})
});
return isTagged;
} else {
return false;
}
}
/**
* Of all available events, selects those that fall within the time range,
* and if TAGS are being used, select them if their tags are enabled
*/
export const getFilteredEvents = createSelector(
[getEvents, getTagsFilter, getRangeFilter],
(events, tagFilters, rangeFilter) => {
return events.reduce((acc, value) => {
const noTags = (tagFilters.length === 0 || !process.env.features.USE_TAGS || tagFilters.every(t => !t.active));
const isTagged = (noTags) || isTaggedIn(value, tagFilters);
const isRange = (rangeFilter[0] < d3.timeParse("%Y-%m-%dT%H:%M:%S")(value.timestamp)) &&
(d3.timeParse("%Y-%m-%dT%H:%M:%S")(value.timestamp) < rangeFilter[1]);
if (isRange && isTagged) {
const event = Object.assign({}, value);
acc[event.id] = event;
}
return acc;
}, []);
});
/**
* Of all the filtered events, group them by location and return a list of
* locations with at least one event in it, based on the time range and tags
*/
export const getFilteredLocations = createSelector(
[getFilteredEvents],
(events) => {
const filteredLocations = {};
events.forEach(event => {
const location = event.location;
if (filteredLocations[location]) {
filteredLocations[location].events.push(event);
} else {
filteredLocations[location] = {
label: location,
events: [event],
latitude: event.latitude,
longitude: event.longitude
}
}
})
// Make locations an array are remove if any are undefined
return Object.values(filteredLocations).filter(item => item);
});
// Filter categories
export const getFilteredCategories = createSelector(
[getCategories],
(categories) => {
return Object.values(categories);
});
/**
* Return categories by group
*/
export const getCategoryGroups = createSelector(
[getFilteredCategories],
(categories) => {
const groups = {};
categories.forEach((t) => { if (t.group && !groups[t.group]) { groups[t.group] = t.group_label } });
return Object.keys(groups).concat(['other']);
}
)
/**
* Given a tree of tags, return those tags as a list, where each node has been
* aware of its depth, and given an 'active' flag
*/
export const getTagFilters = createSelector(
[getTags],
(tags) => {
const allTags = [];
let depth = 0;
function traverseNode(node, depth) {
// do something to node
node.active = (!node.hasOwnProperty('active')) ? false : node.active;
node.depth = depth;
allTags.push(node)
depth = depth + 1;
if (Object.keys(node.children).length > 0) {
Object.values(node.children).forEach((childNode) => {
traverseNode(childNode, depth);
});
}
}
if (tags.key && tags.children) traverseNode(tags, depth)
return allTags;
}
)

16
src/store/index.js Normal file
View File

@@ -0,0 +1,16 @@
import {
createStore,
applyMiddleware,
compose
} from 'redux';
import thunk from 'redux-thunk';
import rootReducer from '../reducers';
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(thunk))
);
export default store;

97
src/store/initial.js Normal file
View File

@@ -0,0 +1,97 @@
// TODO: annotate sections of this state.
// NB: why does this canvas document need to be created?
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
const initial = {
domain: {
events: [],
locations: [],
categories: [],
sites: [],
// Tag tree
tags: { },
notifications: [],
},
app: {
error: null,
highlighted: null,
selected: [],
notifications: [],
filters: {
range: [
d3.timeParse("%Y-%m-%dT%H:%M:%S")("2014-08-22T12:00:00"),
d3.timeParse("%Y-%m-%dT%H:%M:%S")("2014-08-27T12:00:00")
],
tags: [],
categories: [],
views: {
events: true,
coevents: false,
routes: false,
sites: true
},
},
base_uri: 'http://127.0.0.1:8000/', // Modify accordingly on production setup.
isMobile: (/Mobi/.test(navigator.userAgent)),
isWebGL: (gl && gl instanceof WebGLRenderingContext),
language: 'en-US',
mapAnchor: process.env.MAP_ANCHOR,
features: {
USE_TAGS: process.env.features.USE_TAGS,
USE_SEARCH: process.env.features.USE_SEARCH
}
},
ui: {
style: {
colors: {
WHITE: "#efefef",
YELLOW: "#ffd800",
MIDGREY: "rgb(44, 44, 44)",
DARKGREY: "#232323",
PINK: "#F28B50",//rgb(232, 9, 90)",
ORANGE: "#F25835",//rgb(232, 9, 90)",
RED: "rgb(233, 0, 19)",
BLUE: "#F2DE79",//"rgb(48, 103 , 217)",
GREEN: "#4FF2F2",//"rgb(0, 158, 86)",
},
groupColors: {
category_group00: "#FF0000",
category_group01: "#226b22",
category_group02: "#671f6f",
category_group03: "#0000bf",
category_group04: "#d3ce2a",
other: "#FF0000"
},
palette: d3.schemeCategory10,
},
dom: {
timeline: "timeline",
timeslider: "timeslider",
map: "map"
},
flags: {
isFetchingDomain: false,
isFetchingEvents: false,
isView2d: true,
isTimeline: true,
isToolbar: false,
isCardstack: true,
isInfopopup: false,
isNotification: true
},
tools: {
formatter: d3.timeFormat("%d %b, %H:%M"),
formatterWithYear: d3.timeFormat("%d %b %Y, %H:%M"),
parser: d3.timeParse("%Y-%m-%dT%H:%M:%S")
},
components: {
toolbarTab: false,
}
}
};
export default initial;

83
webpack.config.js Normal file
View File

@@ -0,0 +1,83 @@
const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const userConfig = require('./config');
const devMode = process.env.NODE_ENV !== 'production';
const path = require('path');
const APP_DIR = path.resolve(__dirname, './src');
const BUILD_DIR = path.resolve(__dirname, './build');
const config = {
entry: {
index: `${APP_DIR}/index.jsx`,
},
devtool: 'source-map',
module: {
rules: [
{
test: /\.scss$/,
include: `${APP_DIR}`,
use: [
devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader'
]
}, {
test: /\.js(x)?$/,
exclude: /node_modules/,
include: `${APP_DIR}`,
use: {
loader: 'babel-loader'
},
}, {
test: /\.(eot|svg|otf|ttf|woff|woff2)$/,
use: {
loader: 'file-loader',
}
},
],
},
node: {
net: 'empty',
tls: 'empty',
dns: 'empty'
},
resolve: {
extensions: ['*', '.js', ],
},
output: {
path: BUILD_DIR,
filename: 'js/[name].bundle.js',
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify('production'),
'MAPBOX_TOKEN': JSON.stringify(userConfig.MAPBOX_TOKEN),
'SERVER_ROOT': JSON.stringify(userConfig.SERVER_ROOT),
'EVENT_EXT': JSON.stringify(userConfig.EVENT_EXT),
'CATEGORY_EXT': JSON.stringify(userConfig.CATEGORY_EXT),
'TAG_TREE_EXT': JSON.stringify(userConfig.TAG_TREE_EXT),
'SITES_EXT': JSON.stringify(userConfig.SITES_EXT),
'EVENT_DESC_ROOT': JSON.stringify(userConfig.EVENT_DESC_ROOT),
'MAP_ANCHOR': JSON.stringify(userConfig.MAP_ANCHOR),
'INCOMING_DATETIME_FORMAT': JSON.stringify(userConfig.INCOMING_DATETIME_FORMAT),
'features': {
'USE_TAGS': JSON.stringify(userConfig.features.USE_TAGS),
'USE_SEARCH': JSON.stringify(userConfig.features.USE_SEARCH),
'USE_SITES': JSON.stringify(userConfig.features.USE_SITES)
}
}
}),
new MiniCssExtractPlugin({
filename: devMode ? '[name].css' : '[name].[hash].css',
chunkFilename: devMode ? '[id].css' : '[id].[hash].css',
}),
new HtmlWebpackPlugin({
template: './index.html',
})
],
};
module.exports = config;

5702
yarn.lock Normal file

File diff suppressed because it is too large Load Diff