Files
ukraine-timemap/src/components/Layout.js
2022-03-20 08:16:47 +01:00

459 lines
14 KiB
JavaScript

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";
import { isMobileOnly } from "react-device-detect";
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() {
// if (!this.props.app.isMobile) {
this.props.actions.fetchDomain().then((domain) =>
this.props.actions.updateDomain({
domain,
features: this.props.features,
})
);
// }
// 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);
// 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(isMobile, styles) {
const checkMobile = isMobileOnly || window.innerWidth < 600;
const { app, actions } = this.props;
const extraContent = checkMobile ? (
<div style={{ position: "relative", bottom: 0 }}>
<h3 style={{ color: "var(--error-red)" }}>
This platform may not work correctly on mobile. If possible, please
re-visit the site on a device with a larger screen.
</h3>
</div>
) : null;
let searchParams = new URLSearchParams(window.location.href.split("?")[1]);
return (
<Popup
title="Introduction to the platform"
theme="dark"
isOpen={
app.flags.isIntropopup &&
(!searchParams.has("cover") || searchParams.get("cover") !== "false")
}
onClose={actions.toggleIntroPopup}
content={app.intro}
styles={styles}
isMobile={false}
>
{extraContent}
</Popup>
);
}
render() {
const { actions, app, domain, features } = this.props;
const dateHeight = 80;
const padding = 2;
const checkMobile = isMobileOnly || window.innerWidth < 600;
const popupStyles = {
height: checkMobile ? "100vh" : "fit-content",
display: checkMobile ? "block" : "table",
width: checkMobile
? "100vw"
: window.innerWidth > 768
? "60vw"
: "calc(100vw - var(--toolbar-width))",
maxWidth: checkMobile ? "100vw" : 600,
maxHeight: checkMobile
? "100vh"
: window.innerHeight > 768
? `calc(100vh - ${app.timeline.dimensions.height}px - ${dateHeight}px)`
: "100vh",
left: checkMobile ? padding : "var(--toolbar-width)",
top: 0,
overflowY: "scroll",
textAlign: "justify",
};
// if (checkMobile) {
// const msg =
// "This platform is not suitable for mobile. Please re-visit the site on a device with a larger screen.";
// return (
// <div>
// {features.USE_COVER && !app.intro && (
// <StaticPage showing={app.flags.isCover}>
// {/* enable USE_COVER in config.js features, and customise your header */}
// {/* pass 'actions.toggleCover' as a prop to your custom header */}
// <TemplateCover
// showAppHandler={() => {
// /* eslint-disable no-undef */
// alert(msg);
// /* eslint-enable no-undef */
// }}
// />
// </StaticPage>
// )}
// {app.intro && <>{this.renderIntroPopup(true, popupStyles)}</>}
// {!app.intro && !features.USE_COVER && (
// <div className="fixedTooSmallMessage">{msg}</div>
// )}
// </div>
// );
// }
return (
<div>
{checkMobile ? null : (
<Toolbar
isNarrative={!!app.associations.narrative}
methods={{
onTitle: actions.toggleCover,
onSelectFilter: (filters) =>
actions.toggleAssociations("filters", filters),
onCategoryFilter: (categories) =>
actions.toggleAssociations("categories", categories),
onShapeFilter: actions.toggleShapes,
onSelectNarrative: this.setNarrative,
}}
/>
)}
<Space
kind={"map" in app ? "map" : "space3d"}
onKeyDown={this.onKeyDown}
methods={{
onSelectNarrative: this.setNarrative,
getCategoryColor: this.getCategoryColor,
onSelect: app.associations.narrative
? this.selectNarrativeStep
: (ev) => this.handleSelect(ev, 1),
}}
/>
{checkMobile ? null : (
<Timeline
onKeyDown={this.onKeyDown}
methods={{
onSelect: app.associations.narrative
? this.selectNarrativeStep
: (ev) => this.handleSelect(ev, 0),
onUpdateTimerange: actions.updateTimeRange,
getCategoryColor: this.getCategoryColor,
}}
/>
)}
<CardStack
timelineDims={app.timeline.dimensions}
onViewSource={this.handleViewSource}
onSelect={
app.associations.narrative ? this.selectNarrativeStep : () => null
}
onHighlight={this.handleHighlight}
onToggleCardstack={() => actions.updateSelected([])}
getCategoryColor={this.getCategoryColor}
/>
<NarrativeControls
narrative={
app.associations.narrative
? {
...app.associations.narrative,
current: this.props.narrativeIdx,
}
: null
}
methods={{
onNext: () => this.selectNarrativeStep(this.props.narrativeIdx + 1),
onPrev: () => this.selectNarrativeStep(this.props.narrativeIdx - 1),
onSelectNarrative: this.setNarrative,
}}
/>
<InfoPopup
language={app.language}
styles={popupStyles}
isOpen={app.flags.isInfopopup}
onClose={actions.toggleInfoPopup}
/>
{this.renderIntroPopup(false, popupStyles)}
{app.debug ? (
<Notification
isNotification={app.flags.isNotification}
notifications={domain.notifications}
onToggle={actions.markNotificationsRead}
/>
) : null}
{features.USE_SEARCH && (
<Search
narrative={app.narrative}
queryString={app.searchQuery}
events={domain.events}
onSearchRowClick={this.handleSelect}
/>
)}
{app.source ? (
<MediaOverlay
source={app.source}
onCancel={() => {
actions.updateSource(null);
}}
/>
) : null}
<LoadingOverlay
isLoading={app.loading || app.flags.isFetchingDomain}
ui={app.flags.isFetchingDomain}
language={app.language}
/>
{features.USE_COVER && (
<StaticPage showing={app.flags.isCover}>
{/* enable USE_COVER in config.js features, and customise your header */}
{/* pass 'actions.toggleCover' as a prop to your custom header */}
<TemplateCover
showing={app.flags.isCover}
showAppHandler={actions.toggleCover}
/>
</StaticPage>
)}
</div>
);
}
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(actions, dispatch),
};
}
export default connect(
(state) => ({
...state,
narrativeIdx: selectors.selectNarrativeIdx(state),
narratives: selectors.selectNarratives(state),
selected: selectors.selectSelected(state),
}),
mapDispatchToProps
)(Dashboard);