diff --git a/example.config.js b/example.config.js index 852b944..51891ce 100644 --- a/example.config.js +++ b/example.config.js @@ -20,19 +20,26 @@ module.exports = { }, store: { app: { - mapAnchor: [31.356397, 34.784818], - filters: { - // timerange: [ - // new Date(2015, 7, 9), - // new Date(2015, 10, 6, 23) - // ] - } + map: { + anchor: [31.356397, 34.784818] + }, + timeline: { + range: [ + new Date(2014, 7, 9), + new Date(2014, 10, 6, 23) + ], + rangeLimits: [ + new Date(2014, 5, 9), + new Date(2018, 1, 6, 23) + ] + } } }, ui: { style: { categories: {}, shapes: {}, - narratives: {} + narratives: {}, + selectedEvent: {}, } } } diff --git a/src/components/Card.jsx b/src/components/Card.jsx index 4757b32..5b27314 100644 --- a/src/components/Card.jsx +++ b/src/components/Card.jsx @@ -61,6 +61,7 @@ class Card extends React.Component { ) } @@ -87,11 +88,27 @@ class Card extends React.Component { // NB: should be internaionalized. renderTimestamp () { + let timelabel = this.makeTimelabel(this.props.event.timestamp) + + let precision = this.props.event.time_display + if (precision === '_date_only') { + precision = '' + timelabel = timelabel.substring(0, 11) + } else if (precision === '_approximate_date_only') { + precision = ' (Approximate date)' + timelabel = timelabel.substring(0, 11) + } else if (precision === '_approximate_datetime') { + precision = ' (Approximate datetime)' + } else { + timelabel = timelabel.substring(0, 11) + } + return ( this.makeTimelabel(timestamp)} + makeTimelabel={timelabel} language={this.props.language} - timestamp={this.props.event.timestamp} + timelabel={timelabel} + precision={precision} /> ) } @@ -143,9 +160,14 @@ class Card extends React.Component { } render () { - const { isSelected } = this.props + const { isSelected, idx } = this.props + return ( -
  • +
  • {this.renderMain()} {this.state.isOpen ? this.renderExtra() : null} {isSelected ? this.renderCaret() : null} @@ -154,4 +176,5 @@ class Card extends React.Component { } } -export default Card +// The ref to each card will be used in CardStack for programmatic scrolling +export default React.forwardRef((props, ref) => ) diff --git a/src/components/CardStack.jsx b/src/components/CardStack.jsx index f2e908c..c23a83c 100644 --- a/src/components/CardStack.jsx +++ b/src/components/CardStack.jsx @@ -6,13 +6,62 @@ import Card from './Card.jsx' import copy from '../js/data/copy.json' class CardStack extends React.Component { + constructor () { + super() + this.refs = {} + this.refCardStack = React.createRef() + this.refCardStackContent = React.createRef() + } + + componentDidUpdate () { + const isNarrative = !!this.props.narrative + + if (isNarrative) { + this.scrollToCard() + } + } + + scrollToCard () { + const duration = 500 + const element = this.refCardStack.current + const cardScroll = this.refs[this.props.narrative.current].current.offsetTop - 20 + + let start = element.scrollTop + let change = cardScroll - start + let currentTime = 0 + const increment = 20 + + // t = current time + // b = start value + // c = change in value + // d = duration + Math.easeInOutQuad = function (t, b, c, d) { + t /= d / 2 + if (t < 1) return c / 2 * t * t + b + t -= 1 + return -c / 2 * (t * (t - 2) - 1) + b + } + + const animateScroll = function () { + currentTime += increment + const val = Math.easeInOutQuad(currentTime, start, change, duration) + element.scrollTop = val + if (currentTime < duration) setTimeout(animateScroll, increment) + } + animateScroll() + } + renderCards (events, selections) { // if no selections provided, select all if (!selections) { selections = events.map(e => true) } + this.refs = [] - return events.map((event, idx) => ( - { + const thisRef = React.createRef() + this.refs[idx] = thisRef + return ( - )) + />) + }) } renderSelectedCards () { @@ -38,9 +87,10 @@ class CardStack extends React.Component { renderNarrativeCards () { const { narrative } = this.props - const showing = narrative.steps.slice(narrative.current) + const showing = narrative.steps + const selections = showing - .map((_, idx) => (idx === 0)) + .map((_, idx) => (idx === narrative.current)) return this.renderCards(showing, selections) } @@ -74,7 +124,9 @@ class CardStack extends React.Component { renderNarrativeContent () { return ( -
    +
      {this.renderNarrativeCards()}
    @@ -102,6 +154,7 @@ class CardStack extends React.Component { return (
    { + function onClickCheckbox (obj, type) { + obj.active = !obj.active + props.onCategoryFilter(obj) + } + + function renderCategoryTree () { + return ( +
    +

    {copy[props.language].toolbar.categories}

    + {props.categories.map(cat => { + return (
  • + onClickCheckbox(cat, 'category')} + /> +
  • ) + })} + + ) + } + + return ( +
    +

    {copy[props.language].toolbar.explore_by_category__title}

    +

    {copy[props.language].toolbar.explore_by_category__description}

    + {renderCategoryTree()} +
    + ) +} diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index fb60c28..10e1485 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -92,6 +92,7 @@ class Dashboard extends React.Component { render () { const { actions, app, domain, ui } = this.props + return (
    state, - // state => injectSource("Youtube - Novodvirske Tank Separatist Patrol Video"), mapDispatchToProps )(Dashboard) diff --git a/src/components/Map.jsx b/src/components/Map.jsx index 7074381..97cf11b 100644 --- a/src/components/Map.jsx +++ b/src/components/Map.jsx @@ -192,6 +192,7 @@ class Map extends React.Component { */ styleLocation (location) { const noEvents = location.events.length + return [ null, () => noEvents > 1 ? {noEvents} : null @@ -220,6 +221,7 @@ class Map extends React.Component { svg={this.svgRef.current} selected={this.props.app.selected} projectPoint={this.projectPoint} + styles={this.props.ui.mapSelectedEvents} /> ) } @@ -279,6 +281,7 @@ function mapStateToProps (state) { tiles: state.ui.tiles, dom: state.ui.dom, narratives: state.ui.style.narratives, + mapSelectedEvents: state.ui.style.selectedEvents, shapes: state.ui.style.shapes } } diff --git a/src/components/SourceOverlay.jsx b/src/components/SourceOverlay.jsx index cce77f5..5b3a254 100644 --- a/src/components/SourceOverlay.jsx +++ b/src/components/SourceOverlay.jsx @@ -9,16 +9,7 @@ import NoSource from './presentational/NoSource' class SourceOverlay extends React.Component { constructor () { super() - - this.state = { - idx: 0 - } - } - - renderError () { - return ( - - ) + this.state = { idx: 0 } } renderImage (path) { @@ -27,7 +18,7 @@ class SourceOverlay extends React.Component {
    } + loader={
    } unloader={} /> @@ -35,7 +26,6 @@ class SourceOverlay extends React.Component { } renderVideo (path) { - // NB: assume only one video return (
    { - counts[m.type] += 1 - }) - return counts + return media.reduce( + (acc, vl) => { + acc[vl.type] += 1 + return acc + }, + { Image: 0, Video: 0, Text: 0 } + ) } _renderPath (media) { @@ -122,26 +114,49 @@ class SourceOverlay extends React.Component { _renderContent (media) { const el = document.querySelector(`.source-media-gallery`) - const shiftW = (el) ? el.getBoundingClientRect().width : 0 + const shiftW = el ? el.getBoundingClientRect().width : 0 return ( -
    +
    {media.map((m) => this._renderPath(m))}
    ) } onShiftGallery (shift) { + // no more left if (this.state.idx === 0 && shift === -1) return - if (this.state.idx - 1 === this.props.source.paths.length && shift === 1) return + // no more right + if (this.state.idx === this.props.source.paths.length - 1 && shift === 1) return this.setState({ idx: this.state.idx + shift }) } _renderControls () { + const backArrow = this.state.idx !== 0 ? ( +
    this.onShiftGallery(-1)} + > + + + +
    + ) : null + const forwardArrow = this.state.idx < this.props.source.paths.length - 1 ? ( +
    this.onShiftGallery(1)} + > + + + +
    + ) : null + if (this.props.source.paths.length > 1) { return (
    -
    this.onShiftGallery(-1)}>
    -
    this.onShiftGallery(1)}>
    + {backArrow} + {forwardArrow}
    ) } @@ -159,12 +174,12 @@ class SourceOverlay extends React.Component { return (
    -
    { e.stopPropagation() }}> +
    e.stopPropagation()}>
    -
    {this.props.source.title}
    +
    {this.props.source.title.substring(0, 200)}
    {this._renderContent(media)} diff --git a/src/components/TagListPanel.jsx b/src/components/TagListPanel.jsx index c153a7b..1d88b73 100644 --- a/src/components/TagListPanel.jsx +++ b/src/components/TagListPanel.jsx @@ -22,8 +22,7 @@ class TagListPanel extends React.Component { onClickCheckbox (obj, type) { obj.active = !obj.active - if (type === 'category') this.props.onCategoryFilter(obj) - if (type === 'tag') this.props.onTagFilter(obj) + this.props.onTagFilter(obj) } createNodeComponent (node, depth) { @@ -70,34 +69,11 @@ class TagListPanel extends React.Component { ) } - renderCategoryTree () { - return ( -
    -

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

    - {this.props.categories.map(cat => { - return (
  • - this.onClickCheckbox(cat, 'category')} - /> -
  • ) - }) - } -
    - ) - } - render () { return (

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

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

    - {this.renderCategoryTree()} {this.renderTree()}
    ) diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 6bd810f..2f489ea 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -172,9 +172,28 @@ class Timeline extends React.Component { const extent = this.getTimeScaleExtent() const newCentralTime = d3.timeMinute.offset(this.state.scaleX.domain()[0], extent / 2) + let newDomain0 = d3.timeMinute.offset(newCentralTime, -zoom.duration / 2) + let newDomainF = d3.timeMinute.offset(newCentralTime, zoom.duration / 2) + + if (this.props.app.timeline.rangeLimits) { + // If the store contains absolute time limits, + // make sure the zoom doesn't go over them + const minDate = parseDate(this.props.app.timeline.rangeLimits[0]) + const maxDate = parseDate(this.props.app.timeline.rangeLimits[1]) + + if (newDomain0 < minDate) { + newDomain0 = minDate + newDomainF = d3.timeMinute.offset(newDomain0, zoom.duration) + } + if (newDomainF > maxDate) { + newDomainF = maxDate + newDomain0 = d3.timeMinute.offset(newDomainF, -zoom.duration) + } + } + this.setState({ timerange: [ - d3.timeMinute.offset(newCentralTime, -zoom.duration / 2), - d3.timeMinute.offset(newCentralTime, zoom.duration / 2) + newDomain0, + newDomainF ] }, () => { this.props.methods.onUpdateTimerange(this.state.timerange) }) @@ -205,8 +224,18 @@ class Timeline extends React.Component { const timeShift = (drag0 - dragNow) / 1000 const { range } = this.props.app.timeline - const newDomain0 = d3.timeSecond.offset(range[0], timeShift) - const newDomainF = d3.timeSecond.offset(range[1], timeShift) + let newDomain0 = d3.timeSecond.offset(range[0], timeShift) + let newDomainF = d3.timeSecond.offset(range[1], timeShift) + + if (this.props.app.timeline.rangeLimits) { + // If the store contains absolute time limits, + // make sure the zoom doesn't go over them + const minDate = parseDate(this.props.app.timeline.rangeLimits[0]) + const maxDate = parseDate(this.props.app.timeline.rangeLimits[1]) + + newDomain0 = (newDomain0 < minDate) ? minDate : newDomain0 + newDomainF = (newDomainF > maxDate) ? maxDate : newDomainF + } // Updates components without updating timerange this.onSoftTimeRangeUpdate([newDomain0, newDomainF]) @@ -290,15 +319,12 @@ class Timeline extends React.Component { dims={dims} onApplyZoom={this.onApplyZoom} /> - {/* */} + + + ) + } + } + renderToolbarTagPanel () { if (process.env.features.USE_TAGS && this.props.tags.children) { @@ -78,17 +94,14 @@ class Toolbar extends React.Component { ) } - return '' + return null } renderToolbarTab (_selected, label, iconKey) { @@ -110,6 +123,7 @@ class Toolbar extends React.Component { {this.renderClosePanel()} {this.renderToolbarNarrativePanel()} + {this.renderToolbarCategoriesPanel()} {this.renderToolbarTagPanel()}}
    @@ -130,7 +144,7 @@ class Toolbar extends React.Component { ) }) } - return '' + return null } renderToolbarTabs () { @@ -138,14 +152,17 @@ class Toolbar extends React.Component { if (process.env.title) title = process.env.title const narrativesLabel = copy[this.props.language].toolbar.narratives_label const tagsLabel = copy[this.props.language].toolbar.tags_label + const categoriesLabel = 'Categories' // TODO: const isTags = this.props.tags && this.props.tags.children + const isCategories = true return (

    {title}

    {this.renderToolbarTab(0, narrativesLabel, 'timeline')} - {(isTags) ? this.renderToolbarTab(1, tagsLabel, 'style') : ''} + {(isCategories) ? this.renderToolbarTab(1, categoriesLabel, 'widgets') : null} + {(isTags) ? this.renderToolbarTab(2, tagsLabel, 'filter_list') : null}
    { +const CardLocation = ({ language, location, isPrecise }) => { if (isNotNullNorUndefined(location)) { return (

    location_on - {location} + {`${location}${(isPrecise) ? '' : ' (Approximated)'}`}

    ) diff --git a/src/components/presentational/Card/Timestamp.js b/src/components/presentational/Card/Timestamp.js index ee95994..f08d660 100644 --- a/src/components/presentational/Card/Timestamp.js +++ b/src/components/presentational/Card/Timestamp.js @@ -3,18 +3,17 @@ import React from 'react' import copy from '../../../js/data/copy.json' import { isNotNullNorUndefined } from '../../../js/utilities' -const CardTimestamp = ({ makeTimelabel, language, timestamp }) => { +const CardTimestamp = ({ timelabel, language, precision }) => { // const daytimeLang = copy[language].cardstack.timestamp // const estimatedLang = copy[language].cardstack.estimated const unknownLang = copy[language].cardstack.unknown_time - if (isNotNullNorUndefined(timestamp)) { - const timelabel = makeTimelabel(timestamp) + if (isNotNullNorUndefined(timelabel)) { return (

    today - {timelabel} + {timelabel}{(precision !== '') ? ` - ${precision}` : ''}

    ) diff --git a/src/components/presentational/Map/Events.jsx b/src/components/presentational/Map/Events.jsx index 968fddf..e4ca77c 100644 --- a/src/components/presentational/Map/Events.jsx +++ b/src/components/presentational/Map/Events.jsx @@ -2,20 +2,71 @@ import React from 'react' import { Portal } from 'react-portal' function MapEvents ({ getCategoryColor, categories, projectPoint, styleLocation, narrative, onSelect, svg, locations }) { - // function getLocationEventsDistribution (location) { - // const eventCount = {} - // - // categories.forEach(cat => { - // eventCount[cat.category] = [] - // }) - // - // location.events.forEach((event) => { - // ; - // eventCount[event.category].push(event) - // }) - // - // return eventCount - // } + function getCoordinatesForPercent (radius, percent) { + const x = radius * Math.cos(2 * Math.PI * percent) + const y = radius * Math.sin(2 * Math.PI * percent) + return [x, y] + } + + function renderLocationSlicesByCategory (location) { + const locCategory = location.events.length > 0 ? location.events[0].category : 'default' + const customStyles = styleLocation ? styleLocation(location) : null + const extraStyles = customStyles[0] + + let styles = ({ + fill: getCategoryColor(locCategory), + stroke: '#ffffff', + strokeWidth: 0, + fillOpacity: 0.85, + ...extraStyles + }) + + const colorSlices = location.events.map(e => getCategoryColor(e.category)) + + let cumulativeAngleSweep = 0 + + return ( + + {colorSlices.map((color, idx) => { + const r = 10 + + // Based on the number of events in each location, + // create a slice per event filled with its category color + const [startX, startY] = getCoordinatesForPercent(r, cumulativeAngleSweep) + + cumulativeAngleSweep = (idx + 1) / colorSlices.length + + const [endX, endY] = getCoordinatesForPercent(r, cumulativeAngleSweep) + + // if the slices are less than 2, take the long arc + const largeArcFlag = (colorSlices.length === 1) ? 1 : 0 + + // create an array and join it just for code readability + const arc = [ + `M ${startX} ${startY}`, // Move + `A ${r} ${r} 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc + `L 0 0 `, // Line + `L ${startX} ${startY} Z` // Line + ].join(' ') + + const extraStyles = ({ + ...styles, + fill: color + }) + + return ( + + ) + })} + + + ) + } function renderLocation (location) { /** @@ -27,18 +78,6 @@ function MapEvents ({ getCategoryColor, categories, projectPoint, styleLocation, } */ const { x, y } = projectPoint([location.latitude, location.longitude]) - // const eventsByCategory = getLocationEventsDistribution(location); - - const locCategory = location.events.length > 0 ? location.events[0].category : 'default' - const customStyles = styleLocation ? styleLocation(location) : null - const extraStyles = customStyles[0] - const extraRender = customStyles[1] - - const styles = ({ - fill: getCategoryColor(locCategory), - fillOpacity: 1, - ...extraStyles - }) // in narrative mode, only render events in narrative if (narrative) { @@ -51,19 +90,19 @@ function MapEvents ({ getCategoryColor, categories, projectPoint, styleLocation, } } + const customStyles = styleLocation ? styleLocation(location) : null + const extraRender = (customStyles) ? customStyles[1] : null + return ( onSelect(location.events)} > - + {renderLocationSlicesByCategory(location)} {extraRender ? extraRender() : null} + ) } diff --git a/src/components/presentational/Map/Narratives.jsx b/src/components/presentational/Map/Narratives.jsx index 4424900..e3957d7 100644 --- a/src/components/presentational/Map/Narratives.jsx +++ b/src/components/presentational/Map/Narratives.jsx @@ -1,5 +1,7 @@ import React from 'react' import { Portal } from 'react-portal' +// import { concatStatic } from 'rxjs/operator/concat' +// import { single } from 'rxjs/operator/single' function MapNarratives ({ styles, onSelectNarrative, svg, narrative, narratives, projectPoint }) { function getNarrativeStyle (narrativeId) { @@ -28,7 +30,7 @@ function MapNarratives ({ styles, onSelectNarrative, svg, narrative, narratives, // 0 if not in narrative mode, 1 if active narrative, 0.1 if inactive let styles = { strokeOpacity: (n === null) ? 0 - : (step && (n.id === narrative.id)) ? 1 : 0.1, + : (step && (n.id === narrative.id)) ? 1 : 0.0, strokeWidth: 0, strokeDasharray: 'none', stroke: 'none' @@ -59,24 +61,67 @@ function MapNarratives ({ styles, onSelectNarrative, svg, narrative, narratives, } } + function _renderNarrativeStepArrow (p1, p2, styles) { + const distance = Math.sqrt((p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y)) + const theta = Math.atan2(p2.y - p1.y, p2.x - p1.x) // Angle of narrative step line + const alpha = Math.atan2(1, 2) // Angle of arrow overture + const edge = 10 // Arrow edge length + const offset = (distance < 24) ? distance / 2 : 24 + + // Arrow corners + const coord0 = { + x: p2.x - offset * Math.cos(theta), + y: p2.y - offset * Math.sin(theta) + } + const coord1 = { + x: coord0.x - edge * Math.cos(-theta - alpha), + y: coord0.y + edge * Math.sin(-theta - alpha) + } + const coord2 = { + x: coord0.x - edge * Math.cos(-theta + alpha), + y: coord0.y + edge * Math.sin(-theta + alpha) + } + + return () + } + function _renderNarrativeStep (p1, p2, styles) { const { stroke, strokeWidth, strokeDasharray, strokeOpacity } = styles + return ( - onSelectNarrative(n)} - style={{ - strokeWidth, - strokeDasharray, - strokeOpacity, - stroke - }} - /> + + onSelectNarrative(n)} + style={{ + strokeWidth, + strokeDasharray, + strokeOpacity, + stroke + }} + /> + {(stroke !== 'none') + ? _renderNarrativeStepArrow(p1, p2, styles) + : '' + } + ) } diff --git a/src/components/presentational/Map/SelectedEvents.jsx b/src/components/presentational/Map/SelectedEvents.jsx index abbf984..f47b538 100644 --- a/src/components/presentational/Map/SelectedEvents.jsx +++ b/src/components/presentational/Map/SelectedEvents.jsx @@ -4,21 +4,23 @@ import { Portal } from 'react-portal' class MapSelectedEvents extends React.Component { renderMarker (event) { const { x, y } = this.props.projectPoint([event.latitude, event.longitude]) + const styles = this.props.styles + const r = styles ? styles.r : 24 return ( ) diff --git a/src/components/presentational/Timeline/Markers.js b/src/components/presentational/Timeline/Markers.js index 79166b1..8f01dd4 100644 --- a/src/components/presentational/Timeline/Markers.js +++ b/src/components/presentational/Timeline/Markers.js @@ -1,12 +1,18 @@ import React from 'react' -const TimelineMarkers = ({ getEventX, getCategoryY, transitionDuration, selected }) => { +const TimelineMarkers = ({ styles, getEventX, getCategoryY, transitionDuration, selected }) => { function renderMarker (event) { return ( state.app.narrative export const getActiveStep = state => state.app.narrativeState.current export const getSelected = state => state.app.selected export const getSites = (state) => { - if (process.env.features.USE_SITES) return state.domain.sites + if (process.env.features.USE_SITES) return state.domain.sites.filter(s => !!(+s.enabled)) return [] } export const getSources = state => {