import React from "react"; import { bindActionCreators } from "redux"; import { connect } from "react-redux"; import * as actions from "../actions"; import * as selectors from "../selectors"; import Toolbar from "./Toolbar"; import InfoPopup from "./InfoPopup"; import Notification from "./Notification"; import TemplateCover from "./TemplateCover"; import Popup from "./atoms/Popup"; import StaticPage from "./atoms/StaticPage"; import MediaOverlay from "./atoms/Media"; import LoadingOverlay from "./atoms/Loading"; import Timeline from "./time/Timeline"; import Space from "./space/Space"; import Search from "./controls/Search"; import CardStack from "./controls/CardStack"; import NarrativeControls from "./controls/NarrativeControls.js"; import colors from "../common/global"; import { binarySearch, insetSourceFrom } from "../common/utilities"; class Dashboard extends React.Component { constructor(props) { super(props); this.handleViewSource = this.handleViewSource.bind(this); this.handleHighlight = this.handleHighlight.bind(this); this.setNarrative = this.setNarrative.bind(this); this.setNarrativeFromFilters = this.setNarrativeFromFilters.bind(this); this.handleSelect = this.handleSelect.bind(this); this.getCategoryColor = this.getCategoryColor.bind(this); this.findEventIdx = this.findEventIdx.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.selectNarrativeStep = this.selectNarrativeStep.bind(this); } componentDidMount() { this.props.actions.fetchDomain().then((domain) => { this.props.actions.updateDomain({ domain, features: this.props.features, }); this.props.actions.rehydrateState(); }); // NOTE: hack to get the timeline to always show. Not entirely sure why // this is necessary. window.dispatchEvent(new Event("resize")); } handleHighlight(highlighted) { this.props.actions.updateHighlighted(highlighted || null); } handleViewSource(source) { this.props.actions.updateSource(source); } findEventIdx(theEvent) { const { events } = this.props.domain; return binarySearch(events, theEvent, (theev, otherev) => { return theev.datetime - otherev.datetime; }); } handleSelect(selected, axis) { if (selected.length <= 0) { this.props.actions.updateSelected([]); return; } const matchedEvents = []; const TIMELINE_AXIS = 0; if (axis === TIMELINE_AXIS) { matchedEvents.push(selected); // find in events const { events } = this.props.domain; const idx = this.findEventIdx(selected); // binary search can return event with different id if (events[idx].id !== selected.id) { matchedEvents.push(events[idx]); } // check events before let ptr = idx - 1; while ( ptr >= 0 && events[idx].datetime.getTime() === events[ptr].datetime.getTime() ) { if (events[ptr].id !== selected.id) { matchedEvents.push(events[ptr]); } ptr -= 1; } // check events after ptr = idx + 1; while ( ptr < events.length && events[idx].datetime.getTime() === events[ptr].datetime.getTime() ) { if (events[ptr].id !== selected.id) { matchedEvents.push(events[ptr]); } ptr += 1; } } else { // Map.. const std = { ...selected }; delete std.sources; Object.values(std).forEach((ev) => matchedEvents.push(ev)); } this.props.actions.updateSelected(matchedEvents); } getCategoryColor(category) { if (!this.props.features.USE_CATEGORIES) { return colors.fallbackEventColor; } const cat = this.props.ui.style.categories[category]; if (cat) { return cat; } else { return this.props.ui.style.categories.default; } } setNarrative(narrative) { // only handleSelect if narrative is not null and has associated events if (narrative && narrative.steps.length >= 1) { this.handleSelect([narrative.steps[0]]); } this.props.actions.updateNarrative(narrative); } setNarrativeFromFilters(withSteps) { const { app, domain } = this.props; let activeFilters = app.associations.filters; if (activeFilters.length === 0) { alert("No filters selected, cant narrativise"); return; } activeFilters = activeFilters.map((f) => ({ name: f })); const evs = domain.events.filter((ev) => { let hasOne = false; // add event if it has at least one matching filter for (let i = 0; i < activeFilters.length; i++) { if (ev.associations.includes(activeFilters[i].name)) { hasOne = true; break; } } if (hasOne) return true; return false; }); if (evs.length === 0) { alert("No associated events, cant narrativise"); return; } const name = activeFilters.map((f) => f.name).join("-"); const desc = activeFilters.map((f) => f.description).join("\n\n"); this.setNarrative({ id: name, label: name, description: desc, withLines: withSteps, steps: evs.map(insetSourceFrom(domain.sources)), }); } selectNarrativeStep(idx) { // Try to find idx if event passed rather than number if (typeof idx !== "number") { const e = idx[0] || idx; if (this.props.app.associations.narrative) { const { steps } = this.props.app.associations.narrative; // choose the first event at a given location const locationEventId = e.id; const narrativeIdxObj = steps.find((s) => s.id === locationEventId); const narrativeIdx = steps.indexOf(narrativeIdxObj); if (narrativeIdx > -1) { idx = narrativeIdx; } } } const { narrative } = this.props.app.associations; if (narrative === null) return; if (idx < narrative.steps.length && idx >= 0) { const step = narrative.steps[idx]; this.handleSelect([step]); this.props.actions.updateNarrativeStepIdx(idx); } } onKeyDown(e) { const { narrative, selected } = this.props.app; const { events } = this.props.domain; const prev = (idx) => { if (narrative === null) { this.handleSelect(events[idx - 1], 0); } else { this.selectNarrativeStep(this.props.narrativeIdx - 1); } }; const next = (idx) => { if (narrative === null) { this.handleSelect(events[idx + 1], 0); } else { this.selectNarrativeStep(this.props.narrativeIdx + 1); } }; if (selected.length > 0) { const ev = selected[selected.length - 1]; const idx = this.findEventIdx(ev); switch (e.keyCode) { case 37: // left arrow case 38: // up arrow if (idx <= 0) return; prev(idx); break; case 39: // right arrow case 40: // down arrow if (idx < 0 || idx >= this.props.domain.length - 1) return; next(idx); break; default: } } } renderIntroPopup(styles) { const { app, actions } = this.props; const localStorageKey = "rememberDismissedIntro2"; // can be incremented when new data appears on the cover let searchParams = new URLSearchParams(window.location.href.split("?")[1]); let rememberDismissedIntro = localStorage.getItem(localStorageKey) === "true"; let forceShowIntro = searchParams.get("cover") === "true"; if ( (forceShowIntro || !rememberDismissedIntro) && !searchParams.has("id") ) { return ( { actions.toggleIntroPopup(); localStorage.setItem(localStorageKey, "true"); }} content={app.intro} styles={styles} > ); } else { return null; } } render() { const { actions, app, domain, timeline, features } = this.props; const popupStyles = {}; return (
{ actions.toggleAssociations("filters", filters), onCategoryFilter: (categories) => actions.toggleAssociations("categories", categories), onShapeFilter: actions.toggleShapes, onSelectNarrative: this.setNarrative, }} /> } this.handleSelect(ev, 1), }} /> { this.handleSelect(ev, 0), onUpdateTimerange: actions.updateTimeRange, getCategoryColor: this.getCategoryColor, }} /> } null } onHighlight={this.handleHighlight} onToggleCardstack={() => actions.updateSelected([])} getCategoryColor={this.getCategoryColor} /> this.selectNarrativeStep(this.props.narrativeIdx + 1), onPrev: () => this.selectNarrativeStep(this.props.narrativeIdx - 1), onSelectNarrative: this.setNarrative, }} /> {this.renderIntroPopup(popupStyles)} {app.debug ? ( ) : null} {features.USE_SEARCH && ( )} {app.source ? ( { actions.updateSource(null); }} /> ) : null} {features.USE_COVER && ( {/* enable USE_COVER in config.js features, and customise your header */} {/* pass 'actions.toggleCover' as a prop to your custom header */} )}
); } } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators(actions, dispatch), }; } export default connect( (state) => ({ ...state, timeline: { dimensions: selectors.selectDimensions(state), }, narrativeIdx: selectors.selectNarrativeIdx(state), narratives: selectors.selectNarratives(state), selected: selectors.selectSelected(state), }), mapDispatchToProps )(Dashboard);