diff --git a/src/actions/index.js b/src/actions/index.js index 1264aa2..24d2a6d 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -97,7 +97,6 @@ export function fetchDomain () { sourcesPromise ]) .then(response => { - dispatch(toggleFetchingDomain()) const result = { events: response[0], categories: response[1], @@ -107,11 +106,16 @@ export function fetchDomain () { sources: response[5], notifications } + if (Object.values(result).some(resp => resp.hasOwnProperty('error'))) { + throw new Error('Some URLs returned negative. If you are in development, check the server is running') + } return result }) .catch(err => { dispatch(fetchError(err.message)) dispatch(toggleFetchingDomain()) + // TODO: handle this appropriately in React hierarchy + alert(err.message) }) }; } diff --git a/src/components/Card.jsx b/src/components/Card.jsx index b85a144..35def13 100644 --- a/src/components/Card.jsx +++ b/src/components/Card.jsx @@ -129,11 +129,11 @@ class Card extends React.Component { renderHeader() { return (
-
+
{this.renderTimestamp()} {this.renderLocation()}
- {/* {this.renderCategory()} */} + {this.renderCategory()}
{this.renderSummary()}
diff --git a/src/components/CardStack.jsx b/src/components/CardStack.jsx index bb47d71..4e7f8b0 100644 --- a/src/components/CardStack.jsx +++ b/src/components/CardStack.jsx @@ -36,17 +36,6 @@ class CardStack extends React.Component { 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 (

in:{` ${locationName}`}

) - } - return ''; - } - renderCardStackHeader() { const header_lang = copy[this.props.language].cardstack.header; diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index 479be0a..a462491 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -22,7 +22,7 @@ class Dashboard extends React.Component { this.handleHighlight = this.handleHighlight.bind(this); this.handleSelect = this.handleSelect.bind(this); - // this.handleToggle = this.handleToggle.bind(this); + this.handleSelectNarrative = this.handleSelectNarrative.bind(this); this.handleTagFilter = this.handleTagFilter.bind(this); this.updateTimerange = this.updateTimerange.bind(this); @@ -55,6 +55,10 @@ class Dashboard extends React.Component { } } + handleSelectNarrative(narrative) { + this.props.actions.updateNarrative(narrative); + } + handleTagFilter(tag) { this.props.actions.updateTagFilters(tag); } @@ -76,23 +80,18 @@ class Dashboard extends React.Component { render() { return (
+ this.getCategoryColor(category) }} /> - - this.props.actions.updateSelected([])} - getNarrativeLinks={event => this.getNarrativeLinks(event)} - getCategoryColor={category => this.getCategoryColor(category)} - /> this.getCategoryColor(category) }} /> + {(this.props.app.narrative !== null) + ? + : '' + } + this.props.actions.updateSelected([])} + getNarrativeLinks={event => this.getNarrativeLinks(event)} + getCategoryColor={category => this.getCategoryColor(category)} + /> this.props.actions.toggleInfoPopup()} /> - - { + const step = this.props.narrative.steps[this.state.step]; + this.props.onSelect([step]); + }); } } @@ -34,7 +46,7 @@ class NarrativeCard extends React.Component { return ( @@ -42,16 +54,19 @@ class NarrativeCard extends React.Component { } render() { - if (this.props.narrative !== null && this.props.narrative.steps[this.state.step]) { + if (this.props.narrative.steps[this.state.step]) { const steps = this.props.narrative.steps; const step = steps[this.state.step]; return (
{this.renderClose()} -
{this.props.narrative.label}
+

{this.props.narrative.label}

{this.props.narrative.description}

-

{this.state.step + 1}/{steps.length}. {step.location}

+
+ location_on + {this.state.step + 1}/{steps.length}. {step.location} +
this.goToPrevKeyFrame()}>←
= this.props.narrative.steps.length - 1) ? 'disabled ' : ''} action`} onClick={() => this.goToNextKeyFrame()}>→
diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 273fc98..ae42fb2 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -1,9 +1,11 @@ import React from 'react'; import { connect } from 'react-redux'; import * as selectors from '../selectors'; +import hash from 'object-hash'; import copy from '../js/data/copy.json'; -import { formatterWithYear } from '../js/utilities'; +import { formatterWithYear, isNotNullNorUndefined } from '../js/utilities'; +import TimelineHeader from './presentational/TimelineHeader'; import TimelineLogic from '../js/timeline/timeline.js'; class Timeline extends React.Component { @@ -15,18 +17,14 @@ class Timeline extends React.Component { } componentDidMount() { - const ui = { - dom: this.props.dom - } - - this.timeline = new TimelineLogic(this.props.app, ui, this.props.methods); + this.timeline = new TimelineLogic(this.props.app, this.props.ui, this.props.methods); this.timeline.update(this.props.domain, this.props.app); - this.timeline.render(this.props.domain); } componentWillReceiveProps(nextProps) { - this.timeline.update(nextProps.domain, nextProps.app); - this.timeline.render(nextProps.domain); + if (hash(nextProps) !== hash(this.props)) { + this.timeline.update(nextProps.domain, nextProps.app); + } } onClickArrow() { @@ -35,34 +33,19 @@ class Timeline extends React.Component { }); } - renderLabels() { - const labels = copy[this.props.language].timeline.labels; - return this.props.categories.map((label) => { - const groupLen = this.props.categories.length - return (
{label}
); - }); - } - render() { - const labels_title_lang = copy[this.props.app.language].timeline.labels_title; - const info_lang = copy[this.props.app.language].timeline.info; let classes = `timeline-wrapper ${(this.state.isFolded) ? ' folded' : ''}`; - const date0 = formatterWithYear(this.props.app.timerange[0]); - const date1 = formatterWithYear(this.props.app.timerange[1]); - + classes += (this.props.app.narrative !== null) ? ' narrative-mode' : ''; return (
-
-
this.onClickArrow()}> -

-
-
-

{info_lang}

-

{date0} - {date1}

-
-
+ { this.onClickArrow(); }} + />
-
+
); @@ -80,9 +63,12 @@ function mapStateToProps(state) { timerange: selectors.getTimeRange(state), selected: state.app.selected, language: state.app.language, - zoomLevels: state.app.zoomLevels + zoomLevels: state.app.zoomLevels, + narrative: state.app.narrative }, - dom: state.ui.dom, + ui: { + dom: state.ui.dom, + } } } diff --git a/src/components/Toolbar.jsx b/src/components/Toolbar.jsx index 70fcabc..75826c5 100644 --- a/src/components/Toolbar.jsx +++ b/src/components/Toolbar.jsx @@ -7,6 +7,7 @@ import Search from './Search.jsx'; import TagListPanel from './TagListPanel.jsx'; import ToolbarBottomActions from './ToolbarBottomActions.jsx'; import copy from '../js/data/copy.json'; +import { isNotNullNorUndefined, trimAndEllipse } from '../js/utilities.js'; class Toolbar extends React.Component { @@ -51,21 +52,21 @@ class Toolbar extends React.Component { this.setState({ tabNum: -1 }, () => { - this.props.actions.updateNarrative(narrative); + this.props.onSelectNarrative(narrative); }); } renderToolbarNarrativePanel() { return ( -

Focus stories

-

Here are some highlighted stories

+

{copy[this.props.language].toolbar.narrative_panel_title}

+

{copy[this.props.language].toolbar.narrative_summary}

{this.props.narratives.map((narr) => { return (
) @@ -99,6 +100,7 @@ class Toolbar extends React.Component { return (
{ this.toggleTab(tabNum); }}> + timeline
{label}
); @@ -174,8 +176,9 @@ class Toolbar extends React.Component { } render() { + const isNarrative = isNotNullNorUndefined(this.props.narrative); return ( -
+
{this.renderToolbarTabs()} {this.renderToolbarPanels()}
@@ -193,6 +196,7 @@ function mapStateToProps(state) { categoryFilter: state.app.filters.categories, viewFilters: state.app.filters.views, features: state.app.features, + narrative: state.app.narrative, } } diff --git a/src/components/Viewport.jsx b/src/components/Viewport.jsx index 3bcc808..49f772d 100644 --- a/src/components/Viewport.jsx +++ b/src/components/Viewport.jsx @@ -1,6 +1,8 @@ import React from 'react' import { connect } from 'react-redux' import * as selectors from '../selectors' +import hash from 'object-hash'; + import Map from '../js/map/map.js' import { areEqual } from '../js/utilities.js' @@ -15,12 +17,15 @@ class Viewport extends React.Component { } componentWillReceiveProps(nextProps) { - this.map.update(nextProps.domain, nextProps.app) + if (hash(nextProps) !== hash(this.props)) { + this.map.update(nextProps.domain, nextProps.app) + } } render() { + const classes = this.props.app.narrative ? 'map-wrapper narrative-mode' : 'map-wrapper'; return ( -
+
) @@ -39,7 +44,8 @@ function mapStateToProps(state) { views: state.app.filters.views, selected: state.app.selected, highlighted: state.app.highlighted, - mapAnchor: state.app.mapAnchor + mapAnchor: state.app.mapAnchor, + narrative: state.app.narrative }, ui: { dom: state.ui.dom, diff --git a/src/components/presentational/TimelineHeader.js b/src/components/presentational/TimelineHeader.js new file mode 100644 index 0000000..d75a605 --- /dev/null +++ b/src/components/presentational/TimelineHeader.js @@ -0,0 +1,15 @@ +import React from 'react'; + +const TimelineHeader = ({ title, date0, date1, onClick }) => ( +
+
onClick()}> +

+
+
+

{title}

+

{date0} - {date1}

+
+
+); + +export default TimelineHeader; diff --git a/src/js/data/copy.json b/src/js/data/copy.json index 2feca2b..21702e0 100644 --- a/src/js/data/copy.json +++ b/src/js/data/copy.json @@ -103,7 +103,9 @@ "title": "Directory of tags", "placeholder": "Search" } - } + }, + "narrative_panel_title": "Focus narratives", + "narrative_summary": "Here you can follow some curated stories we have found in the data." }, "timeline": { "zooms": [ diff --git a/src/js/map/map.js b/src/js/map/map.js index 41b1bb6..05739ce 100644 --- a/src/js/map/map.js +++ b/src/js/map/map.js @@ -17,6 +17,7 @@ export default function(newApp, ui, methods) { const app = { selected: [], highlighted: null, + narrative: null, views: Object.assign({}, newApp.views), } @@ -56,7 +57,7 @@ export default function(newApp, ui, methods) { const map = L.map(id) .setView(center, zoom) .setMinZoom(10) - .setMaxZoom(18) + .setMaxZoom(19) .setMaxBounds(maxBoundaries) // NB: configure tile endpoint @@ -97,10 +98,25 @@ Stop and start the development process in terminal after you have added your tok .attr('viewBox', '0 0 6 6') .attr('refX', 3) .attr('refY', 3) - .attr('markerWidth', 14) - .attr('markerHeight', 14) + .attr('markerWidth', 6) + .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') + .style('fill', 'red') + .attr('d', 'M0,3v-3l6,3l-6,3z'); + + svg.insert('defs', 'g') + .append('marker') + .attr('id', 'arrow-off') + .attr('viewBox', '0 0 6 6') + .attr('refX', 3) + .attr('refY', 3) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto') + .append('path') + .style('fill', 'black') + .style('fill-opacity', 0.2) .attr('d', 'M0,3v-3l6,3l-6,3z'); map.on('zoomstart', () => { @@ -159,8 +175,8 @@ Stop and start the development process in terminal after you have added your tok return `translate(${newPoint.x},${newPoint.y})`; }); - g.selectAll('.narrative') - .attr('d', sequenceLine); + svg.selectAll('.narrative') + .each((g, i, nodes) => { return updateNarrativeSteps(g, i, nodes); }); } lMap.on("zoomend viewreset moveend", updateSVG); @@ -232,9 +248,7 @@ Stop and start the development process in terminal after you have added your tok function getLocationEventsDistribution(location) { const eventCount = {}; const categories = domain.categories; - // categories.sort((a, b) => { - // return (+a.slice(-2) > +b.slice(-2)); - // }); + categories.forEach(cat => { eventCount[cat.category] = 0 }); @@ -274,9 +288,8 @@ Stop and start the development process in terminal after you have added your tok .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})`; + const newPoint = projectPoint([+d.latitude, +d.longitude]); + return `translate(${newPoint.x},${newPoint.y})`; }) .on('click', (location) => { methods.onSelect(location.events); @@ -303,6 +316,9 @@ Stop and start the development process in terminal after you have added your tok .transition() .duration(500) .attr('r', d => (d) ? Math.sqrt(16 * d) + 3 : 0); + + eventsDom.selectAll('.location-event-marker') + .style('fill-opacity', '0.1 !important'); } // NB: is this a function to be removed for future features? @@ -340,18 +356,11 @@ Stop and start the development process in terminal after you have added your tok } } - /*const sequenceLine = d3.line() - .x(d => getCoords(d).x) - .y(d => getCoords(d).y)*/ - - const sequenceLine = d3.line() - .x(d => getCoords(d).x + getSVGBoundaries().transformX) - .y(d => getCoords(d).y + getSVGBoundaries().transformY); - /** - * Clears existing narrative layer - * Renders all narrativ as paths - * Adds eventlayer to map - */ + /** + * Clears existing narrative layer + * Renders all narrativ as paths + * Adds eventlayer to map + */ function getNarrativeStyle(narrativeId) { const styleName = narrativeId && narrativeId in narrativeProps @@ -360,61 +369,132 @@ Stop and start the development process in terminal after you have added your tok return narrativeProps[styleName]; } + function getMarker (d) { + if (!d || app.narrative === null) return 'none'; + if (d.id === app.narrative.id) return 'url(#arrow)'; + return 'url(#arrow-off)'; + } + function renderNarratives() { - const narrativesDom = g.selectAll('.narrative') - .data(domain.narratives.map(d => d.steps)) + const narrativesDom = svg.selectAll('.narrative') + .data((app.narrative !== null) ? domain.narratives : []) narrativesDom .exit() .remove(); - let styleName - narrativesDom - .enter().append('path') + if (app.narrative !== null) { + d3.selectAll('#arrow path') + .style('fill', getNarrativeStyle(app.narrative.id).stroke); + } + + const narrativesEnter = narrativesDom + .enter().append('g') + .attr('id', d => 'narrative-' + d.id) .attr('class', 'narrative') - .attr('d', sequenceLine) - .style('stroke-width', d => { - if (!d[0]) return 0; - // Note: [0] is a non-elegant way to get the narrative id out of the first - // event in the narrative sequence - const styleProps = getNarrativeStyle(d[0].narrative); - return styleProps.strokeWidth; - }) - .style('stroke-dasharray', d => { - if (!d[0]) return 'none'; - const styleProps = getNarrativeStyle(d[0].narrative); - return (styleProps.style === 'dotted') ? "2px 5px" : 'none'; - }) - .style('stroke', d => { - if (!d[0]) return 'none'; - const styleProps = getNarrativeStyle(d[0].narrative); - return styleProps.stroke; - }) - .style('fill', 'none'); + + narrativesDom.selectAll('.narrative') + .each((g, i, nodes) => { return updateNarrativeSteps(g, i, nodes); }); } + function updateNarrativeSteps(g, i, nodes) { + const n = d3.select(nodes[i]).data()[0]; + const allsteps = n.steps.slice(); + allsteps.push(n.steps[n.steps.length - 1]); + + const steps = d3.select(nodes[i]).selectAll('.narrative-step') + .data(n.steps) + + steps.enter().append('line') + .attr('class', 'narrative-step') + .attr('x1', d => getCoords(d).x + getSVGBoundaries().transformX) + .attr('x2', (d, j) => { return getCoords(allsteps[j + 1]).x + getSVGBoundaries().transformX; }) + .attr('y1', d => getCoords(d).y + getSVGBoundaries().transformY) + .attr('y2', (d, j) => { return getCoords(allsteps[j + 1]).y + getSVGBoundaries().transformY; }) + .style('stroke-width', d => { + if (!d) return 0; + const styleProps = getNarrativeStyle(n.id); + return styleProps.strokeWidth; + }) + .style('stroke-dasharray', d => { + if (!d) return 'none'; + const styleProps = getNarrativeStyle(n.id); + return (styleProps.style === 'dotted') ? "2px 5px" : 'none'; + }) + .style('stroke', d => { + if (!d || app.narrative === null) return 'none'; + const styleProps = getNarrativeStyle(n.id); + return styleProps.stroke; + }) + .style('stroke-opacity', d => { + if (app.narrative === null) return 0; + if (!d || d.id !== app.narrative.id) return 0.2; + return 1; + }) + .attr('marker-start', (d, j) => !j ? getMarker(n) : 'none') + .attr('marker-end', getMarker(n)) + .attr('mid-marker', getMarker(n)) + .on('click', () => methods.onSelectNarrative(n) ) + + steps + .attr('x1', d => getCoords(d).x + getSVGBoundaries().transformX) + .attr('x2', (d, j) => { return getCoords(allsteps[j + 1]).x + getSVGBoundaries().transformX; }) + .attr('y1', d => getCoords(d).y + getSVGBoundaries().transformY) + .attr('y2', (d, j) => { return getCoords(allsteps[j + 1]).y + getSVGBoundaries().transformY; }) + .style('stroke-width', d => { + if (!d) return 0; + const styleProps = getNarrativeStyle(n.id); + return styleProps.strokeWidth; + }) + .style('stroke-dasharray', d => { + if (!d) return 'none'; + const styleProps = getNarrativeStyle(n.id); + return (styleProps.style === 'dotted') ? "2px 5px" : 'none'; + }) + .style('stroke', d => { + if (!d || app.narrative === null) return 'none'; + const styleProps = getNarrativeStyle(n.id); + return styleProps.stroke; + }) + .style('stroke-opacity', d => { + if (app.narrative === null) return 0; + if (!d || n.id !== app.narrative.id) return 0.2; + return 1; + }) + .attr('marker-start', (d, j) => !j ? getMarker(n) : 'none') + .attr('marker-end', getMarker(n)) + .attr('mid-marker', getMarker(n)) + + steps + .exit() + .remove(); + } /** * Updates displayable data on the map: events, coevents and paths * @param {Object} domain: object of arrays of events, coevs, attacks, paths, sites */ function update(newDomain, newApp) { updateSVG(); + const isNewDomain = (hash(domain) !== hash(newDomain)); + const isNewAppProps = (hash(app) !== hash(newApp)); - if (hash(domain) !== hash(newDomain)) { + if (isNewDomain) { domain.locations = newDomain.locations; domain.narratives = newDomain.narratives; domain.categories = newDomain.categories; domain.sites = newDomain.sites; - renderDomain(); } - if (hash(app) !== hash(newApp)) { + if (isNewAppProps) { + app.views = newApp.views; app.selected = newApp.selected; app.highlighted = newApp.highlighted; - app.views = newApp.views; - - renderSelectedAndHighlight(); + app.mapAnchor = newApp.mapAnchor; + app.narrative = newApp.narrative; } + + if (isNewDomain || isNewAppProps) renderDomain(); + if (isNewAppProps) renderSelectedAndHighlight(); } /** diff --git a/src/js/timeline/timeline.js b/src/js/timeline/timeline.js index 8a009e9..042ea21 100644 --- a/src/js/timeline/timeline.js +++ b/src/js/timeline/timeline.js @@ -9,54 +9,52 @@ import { parseDate, formatterWithYear } from '../utilities'; +import hash from 'object-hash'; import esLocale from '../data/es-MX.json'; import copy from '../data/copy.json'; -export default function(app, ui, methods) { +export default function(newApp, ui, methods) { d3.timeFormatDefaultLocale(esLocale); - const zoomLevels = app.zoomLevels; - let events = []; - let categories = []; - let selected = []; - let timerange = app.timerange; + const domain = { + events: [], + categories: [], + narratives: [] + } + const app = { + timerange: newApp.timerange, + selected: [], + language: newApp.language, + zoomLevels: newApp.zoomLevels + } + + // Dimension of the client + const WIDTH_CONTROLS = 100; + const HEIGHT = 140; + const boundingClient = d3.select(`#${ui.dom.timeline}`).node().getBoundingClientRect(); + let WIDTH = boundingClient.width - WIDTH_CONTROLS; + + // Highlight events with a larger white ring marker + const markerRadius = 15; + + // NB: is it possible to do this with SCSS? + // A: Maybe, although we are using it programmatically here for now + const margin = { left: 120 }; // Drag behavior let dragPos0; let transitionDuration = 500; - // Dimension of the client - const WIDTH_CONTROLS = 100; - 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(timerange) - .range([mg.l, WIDTH]); - - // calculate group step between categories - const groupStep = (106 - 30) / categories.length; - const groupYs = new Array(categories.length); - groupYs.map((g, i) => { - return 30 + i * groupStep; - }); + .domain(app.timerange) + .range([margin.left, WIDTH]); scale.y = d3.scaleOrdinal() - .domain(categories) - .range(groupYs); + /** * Initilize SVG elements and groups @@ -69,6 +67,14 @@ export default function(app, ui, methods) { .attr('width', WIDTH) .attr('height', HEIGHT); + dom.clip = dom.svg.append("svg:clipPath") + .attr("id", "clip") + .append("svg:rect") + .attr("x", margin.left) + .attr("y", 0) + .attr("width", WIDTH - margin.left) + .attr("height", HEIGHT - 25); + dom.controls = d3.select(`#${ui.dom.timeline}`) .append('svg') @@ -76,10 +82,10 @@ export default function(app, ui, methods) { .attr('width', WIDTH_CONTROLS) .attr('height', HEIGHT); + /* * Axis group elements */ - dom.axis = {}; dom.axis.x0 = dom.svg.append('g') @@ -105,11 +111,15 @@ export default function(app, ui, methods) { 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'); + + dom.body = dom.svg.append("g").attr("clip-path", "url(#clip)"); + dom.events = dom.body.append('g'); + dom.markers = dom.body.append('g'); + /* * Time Controls @@ -125,10 +135,11 @@ export default function(app, ui, methods) { dom.zooms = dom.controls.append('g'); dom.zooms.selectAll('.zoom-level-button') - .data(zoomLevels) + .data(app.zoomLevels) .enter().append('text') .attr('class', 'zoom-level-button'); + /* * Initialize axis function and element group */ @@ -152,37 +163,8 @@ export default function(app, ui, methods) { 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; + updateAxis(); - const newDomain0 = d3.timeSecond.offset(timerange[0], timeShift); - const newDomainF = d3.timeSecond.offset(timerange[1], timeShift); - - scale.x.domain([newDomain0, newDomainF]) - render(); - }) - .on('end', () => { - toggleTransition(true); - methods.onUpdateTimerange(scale.x.domain()); - }); - - /* - * SVG groups for marker - */ - - dom.markers = dom.svg.append('g'); /** * Adapt dimensions when resizing @@ -192,6 +174,7 @@ export default function(app, ui, methods) { .getBoundingClientRect().width; } + /** * Resize timeline one window resice */ @@ -201,8 +184,8 @@ export default function(app, ui, methods) { WIDTH = getCurrentWidth() - WIDTH_CONTROLS; dom.svg.attr('width', WIDTH); - scale.x.range([mg.l, WIDTH]); - axis.y.tickSize(WIDTH - mg.l); + scale.x.range([margin.left, WIDTH]); + axis.y.tickSize(WIDTH - margin.left); dom.axis.y.attr('transform', `translate(${WIDTH}, 0)`) render(null); } @@ -210,6 +193,7 @@ export default function(app, ui, methods) { } addResizeListener(); + /** * Return which color event circle should be based on incident type * @param {object} eventPoint data object @@ -218,6 +202,7 @@ export default function(app, ui, methods) { return methods.getCategoryColor(eventPoint.category); } + /** * Given an event, get all the filtered events that happen simultaneously * @param {object} eventPoint: regular eventPoint data @@ -225,10 +210,11 @@ export default function(app, ui, methods) { function getAllEventsAtOnce(eventPoint) { const timestamp = eventPoint.timestamp; const category = eventPoint.category; - return events + return domain.events .filter(event => (event.timestamp === timestamp && category === event.category)) } + /* * Get y height of eventPoint, considering the ordinal Y scale * @param {object} eventPoint: regular eventPoint data @@ -238,6 +224,7 @@ export default function(app, ui, methods) { return scale.y(yGroup); } + /* * Get x position of eventPoint, considering the time scale * @param {object} eventPoint: regular eventPoint data @@ -250,6 +237,7 @@ export default function(app, ui, methods) { 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 @@ -259,8 +247,12 @@ export default function(app, ui, methods) { return (minutes * WIDTH) / allMins; } + + /* + * TODO: Highlight zoom level based on time range selected + */ function highlightZoomLevel(zoom) { - zoomLevels.forEach((level) => { + app.zoomLevels.forEach((level) => { if (level.label === zoom.label) { level.active = true; } else { @@ -272,6 +264,7 @@ export default function(app, ui, methods) { .classed('active', level => level.active); } + /** * Apply zoom level to timeline * @param {object} zoom: zoom level from zoomLevels @@ -289,6 +282,7 @@ export default function(app, ui, methods) { methods.onUpdateTimerange(scale.x.domain()); } + /** * Shift time range by moving forward or backwards * @param {String} direction: 'forward' / 'backwards' @@ -316,6 +310,34 @@ export default function(app, ui, methods) { transitionDuration = (isTransition) ? 500 : 0; } + + /* + * 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(app.timerange[0], timeShift); + const newDomainF = d3.timeSecond.offset(app.timerange[1], timeShift); + + scale.x.domain([newDomain0, newDomainF]); + render(); + }) + .on('end', () => { + toggleTransition(true); + app.timerange = scale.x.domain(); + methods.onUpdateTimerange(scale.x.domain()); + }); + + /** * Highlight event circle on hover */ @@ -325,6 +347,7 @@ export default function(app, ui, methods) { .classed('mouseover', true); } + /** * Unhighlight event when mouse out */ @@ -334,11 +357,12 @@ export default function(app, ui, methods) { .classed('mouseover', false); } + /** * It automatically sets brush timeline to a domain set by the params */ function updateTimeRange() { - scale.x.domain(timerange); + scale.x.domain(app.timerange); axis.x0.scale(scale.x); axis.x1.scale(scale.x); } @@ -351,23 +375,24 @@ export default function(app, ui, methods) { dom.axis.label0 .attr('x', 5) .attr('y', 15) - .text(formatterWithYear(timerange[0])); + .text(formatterWithYear(app.timerange[0])); dom.axis.label1 .attr('x', WIDTH - 5) .attr('y', 15) - .text(formatterWithYear(timerange[1])) + .text(formatterWithYear(app.timerange[1])) .style('text-anchor', 'end'); } + /** - * Makes a circular rinig mark in one particular location at a time + * Makes a circular ring mark in all selected events * @param {object} eventPoint: object with eventPoint data (time, loc, tags) */ function renderHighlight() { const markers = dom.markers .selectAll('circle') - .data(selected); + .data(app.selected); markers .enter() @@ -382,13 +407,14 @@ export default function(app, ui, methods) { markers.exit().remove(); } + /** * Return event circles of different groups */ function renderEvents() { const eventsDom = dom.events .selectAll('.event') - .data(events, d => d.id); + .data(domain.events, d => d.id); eventsDom .exit() @@ -417,6 +443,7 @@ export default function(app, ui, methods) { .attr('r', 5); } + /** * Render axis on timeline and viewbox boundaries */ @@ -437,7 +464,7 @@ export default function(app, ui, methods) { .duration(transitionDuration) .call(axis.x1); - axis.y.tickSize(WIDTH - mg.l); + axis.y.tickSize(WIDTH - margin.left); dom.axis.y .call(axis.y) @@ -453,12 +480,13 @@ export default function(app, ui, methods) { .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) => { + app.zoomLevels.forEach((level, i) => { level.label = zoomLabels[i]; }); @@ -495,49 +523,72 @@ export default function(app, ui, methods) { .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) { - const categories = domain.categories - const groupStep = (106 - 30) / categories.length; - let groupYs = Array.apply(null, Array(categories.length)); + function updateAxis() { + const groupStep = (106 - 30) / domain.categories.length; + let groupYs = Array.apply(null, Array(domain.categories.length)); groupYs = groupYs.map((g, i) => { return 30 + i * groupStep; }); scale.y = d3.scaleOrdinal() - .domain(categories) + .domain(domain.categories) .range(groupYs); axis.y = d3.axisLeft(scale.y) - .tickValues(categories.map(c => c.category)); + .tickValues(domain.categories.map(c => c.category)); } - function update(domain, app) { - updateAxis(domain); - renderAxis(); - events = domain.events; - timerange = app.timerange; - selected = app.selected.slice(0); - updateTimeRange(); + /** + * Updates displayable data on the timeline: events, selected and + * potentially adjusts time range + * @param {Object} newDomain: object of arrays of events and categories + * @param {Object} newApp: object of time range and selected events + */ + function update(newDomain, newApp) { + const isNewDomain = (hash(domain) !== hash(newDomain)); + const isNewAppProps = (hash(app) !== hash(newApp)); + + if (isNewDomain) { + domain.categories = newDomain.categories; + domain.events = newDomain.events; + domain.narratives = newDomain.narratives; + } + + if (isNewAppProps) { + app.timerange = newApp.timerange; + app.selected = newApp.selected.slice(0); + } + + if (isNewDomain || isNewAppProps) renderContent(); + if (isNewAppProps) renderContext(); } - function render() { - renderAxis(); + function renderContext() { renderTimeControls(); renderTimeLabels(); + } + function renderContent() { + updateAxis(); + renderAxis(); renderEvents(); renderHighlight(); } + function render() { + renderContext(); + renderContent(); + } + return { update, - render, }; } diff --git a/src/js/utilities.js b/src/js/utilities.js index c51596c..e9692e2 100644 --- a/src/js/utilities.js +++ b/src/js/utilities.js @@ -44,6 +44,13 @@ export function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } +export function trimAndEllipse(string, stringNum) { + if (string.length > stringNum) { + return string.substring(0, 120) + '...' + } + return string; +} + /** * Return a Date object given a datetime string of the format: "2016-09-10T07:00:00" * @param {string} datetime diff --git a/src/reducers/schema/eventSchema.js b/src/reducers/schema/eventSchema.js index b497841..614eba2 100644 --- a/src/reducers/schema/eventSchema.js +++ b/src/reducers/schema/eventSchema.js @@ -11,7 +11,7 @@ const eventSchema = Joi.object().keys({ longitude: Joi.string().allow('').required(), type: Joi.string().allow(''), category: Joi.string().required(), - narrative: Joi.string().allow(''), + narratives: Joi.array(), sources: Joi.array(), tags: Joi.string().allow(''), comments: Joi.string().allow(''), diff --git a/src/scss/map.scss b/src/scss/map.scss index d2c51cf..4a761f0 100644 --- a/src/scss/map.scss +++ b/src/scss/map.scss @@ -27,14 +27,22 @@ z-index: $hidden; } &.show { - z-index: $map; + z-index: $map; } + &.narrative-mode { + left: 0; + } + .event { fill: $event_default; cursor: pointer; opacity: 0.45; } + .narrative { + cursor: pointer; + } + .link { stroke: $midgrey; fill: none; @@ -147,7 +155,7 @@ fill: $event_default; stroke-width: 0; transition: 0.2s ease; - fill-opacity: 0.8; + /*fill-opacity: 0.8;*/ cursor: pointer; &:hover { diff --git a/src/scss/narrativecard.scss b/src/scss/narrativecard.scss index 3f7f7a5..ace0435 100644 --- a/src/scss/narrativecard.scss +++ b/src/scss/narrativecard.scss @@ -4,9 +4,9 @@ NARRATIVE INFO .narrative-info { position: fixed; top: 10px; - left: 130px; + left: 10px; height: auto; - width: 270px; + width: 370px; box-sizing: border-box; padding: 15px; max-height: calc(100% - 250px); @@ -23,6 +23,17 @@ NARRATIVE INFO h3 { font-size: $large; + font-family: 'Merriweather', 'Georgia', serif; + letter-spacing: 0.1em; + text-transform: uppercase; + font-weight: 100; + } + + h6 { + margin: 10px 0; + i { + font-size: $normal; + } } p { diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 6e8cddf..2205ce1 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -22,6 +22,10 @@ } } + &.narrative-mode { + left: 0; + } + .timeline-header { height: 0px; width: 100%; diff --git a/src/scss/toolbar.scss b/src/scss/toolbar.scss index 9459f1d..445c927 100644 --- a/src/scss/toolbar.scss +++ b/src/scss/toolbar.scss @@ -9,6 +9,10 @@ z-index: $header; background: $midgrey; + &.narrative-mode { + left: -110px; + } + .toolbar { position: relative; width: 110px; @@ -164,6 +168,7 @@ display: flex; align-items: center; justify-content: center; + flex-direction: column; height: 60px; width: 110px; padding: 5px 0 5px 0; @@ -292,37 +297,6 @@ } } - .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; @@ -362,6 +336,14 @@ height: 0; margin: 0; } + + .panel-header { + visibility: hidden; + + .caret { + transform: translate(8px, 5px)rotate(225deg); + } + } } input { @@ -473,7 +455,7 @@ height: 140px; line-height: 140px; width: 100%; - padding: 0; + padding: 10px; border: 1px solid $offwhite; background-size: 100%; color: $offwhite; @@ -489,9 +471,13 @@ transition: 0.2s ease; letter-spacing: 0.15em; } + + p { + text-transform: none; + } } - &:first-child { + /*&:first-child { button { background-image: url("/static/archive/img/scene01.jpg"); } } &:nth-child(2n) { @@ -503,114 +489,7 @@ &.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; - } + }*/ } } @@ -641,10 +520,10 @@ height: 60px; padding: 0; - .tab-caption { + /*.tab-caption { transition: 0.2s ease; opacity: 0; - } + }*/ &:hover { .tab-caption { diff --git a/src/selectors/index.js b/src/selectors/index.js index 32a4e49..0a96eee 100644 --- a/src/selectors/index.js +++ b/src/selectors/index.js @@ -95,16 +95,18 @@ export const selectNarratives = createSelector( events.forEach((evt) => { const isTagged = isTaggedIn(evt, tagFilters) || isNoTags(tagFilters); const isTimeRanged = isTimeRangedIn(evt, timeRange); - const isInNarrative = evt.narrative; + const isInNarrative = evt.narratives.length > 0; - if (!narratives[evt.narrative]) { - narratives[evt.narrative] = { id: evt.narrative, steps: [], byId: {} }; - } + evt.narratives.map(narrative => { + if (!narratives[narrative]) { + narratives[narrative] = { id: narrative, steps: [], byId: {} }; + } - if (/*isTimeRanged && isTagged && */isInNarrative) { - narratives[evt.narrative].steps.push(evt); - narratives[evt.narrative].byId[evt.id] = { next: null, prev: null }; - } + if (isInNarrative) { + narratives[narrative].steps.push(evt); + narratives[narrative].byId[evt.id] = { next: null, prev: null }; + } + }) }); Object.keys(narratives).forEach((key) => { diff --git a/src/store/initial.js b/src/store/initial.js index 03d8fef..3121edc 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -116,10 +116,16 @@ const initial = { narratives: { default: { style: 'solid', // ['dotted', 'solid'] - opacity: 0.5, // range between 0 and 1 - stroke: 'transparent', // Any hex or rgb code - strokeWidth: 2 + opacity: 0.9, // range between 0 and 1 + stroke: 'red', // Any hex or rgb code + strokeWidth: 3 }, + narrative_1: { + style: 'solid', // ['dotted', 'solid'] + opacity: 0.4, // range between 0 and 1 + stroke: '#f18f01', // Any hex or rgb code + strokeWidth: 3 + } } }, dom: {