mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-12 21:38:35 +03:00
Cleaning technical debt (#192)
* abstract Space component to switch out Map * basic viewing possible * restructure components dir * all jsx --> js * App.jsx --> App.js * comment out 3d for now
This commit is contained in:
85
src/components/time/Axis.js
Normal file
85
src/components/time/Axis.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import * as d3 from "d3";
|
||||
import { setD3Locale } from "../../common/utilities";
|
||||
|
||||
const TEXT_HEIGHT = 15;
|
||||
setD3Locale(d3);
|
||||
class TimelineAxis extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.xAxis0Ref = React.createRef();
|
||||
this.xAxis1Ref = React.createRef();
|
||||
this.state = {
|
||||
isInitialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
let fstFmt, sndFmt;
|
||||
|
||||
// 10yrs
|
||||
if (this.props.extent > 5256000) {
|
||||
fstFmt = "%Y";
|
||||
sndFmt = "";
|
||||
// 1yr
|
||||
} else if (this.props.extent > 43200) {
|
||||
sndFmt = "%d %b";
|
||||
fstFmt = "";
|
||||
} else {
|
||||
sndFmt = "%d %b";
|
||||
fstFmt = "%H:%M";
|
||||
}
|
||||
|
||||
const { marginTop, contentHeight } = this.props.dims;
|
||||
if (this.props.scaleX) {
|
||||
this.x0 = d3
|
||||
.axisBottom(this.props.scaleX)
|
||||
.ticks(10)
|
||||
.tickPadding(0)
|
||||
.tickSize(contentHeight - TEXT_HEIGHT - marginTop)
|
||||
.tickFormat(d3.timeFormat(fstFmt));
|
||||
|
||||
this.x1 = d3
|
||||
.axisBottom(this.props.scaleX)
|
||||
.ticks(10)
|
||||
.tickPadding(marginTop)
|
||||
.tickSize(0)
|
||||
.tickFormat(d3.timeFormat(sndFmt));
|
||||
|
||||
if (!this.state.isInitialized) this.setState({ isInitialized: true });
|
||||
}
|
||||
|
||||
if (this.state.isInitialized) {
|
||||
d3.select(this.xAxis0Ref.current)
|
||||
.transition()
|
||||
.duration(this.props.transitionDuration)
|
||||
.call(this.x0);
|
||||
|
||||
d3.select(this.xAxis1Ref.current)
|
||||
.transition()
|
||||
.duration(this.props.transitionDuration)
|
||||
.call(this.x1);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<g
|
||||
ref={this.xAxis0Ref}
|
||||
transform={`translate(0, ${this.props.dims.marginTop})`}
|
||||
clipPath="url(#clip)"
|
||||
className="axis xAxis"
|
||||
/>
|
||||
<g
|
||||
ref={this.xAxis1Ref}
|
||||
transform={`translate(0, ${this.props.dims.marginTop})`}
|
||||
clipPath="url(#clip)"
|
||||
className="axis xAxis"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TimelineAxis;
|
||||
84
src/components/time/Categories.js
Normal file
84
src/components/time/Categories.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import * as d3 from "d3";
|
||||
|
||||
class TimelineCategories extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.grabRef = React.createRef();
|
||||
this.state = {
|
||||
isInitialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (!this.state.isInitialized) {
|
||||
const drag = d3
|
||||
.drag()
|
||||
.on("start", this.props.onDragStart)
|
||||
.on("drag", this.props.onDrag)
|
||||
.on("end", this.props.onDragEnd);
|
||||
|
||||
d3.select(this.grabRef.current).call(drag);
|
||||
|
||||
this.setState({ isInitialized: true });
|
||||
}
|
||||
}
|
||||
|
||||
renderCategory(cat, idx) {
|
||||
const { features, dims } = this.props;
|
||||
const strokeWidth = 1; // dims.trackHeight / (this.props.categories.length + 1)
|
||||
if (
|
||||
features.GRAPH_NONLOCATED &&
|
||||
features.GRAPH_NONLOCATED.categories &&
|
||||
features.GRAPH_NONLOCATED.categories.includes(cat)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<g
|
||||
className="tick"
|
||||
style={{ strokeWidth }}
|
||||
opacity="0.5"
|
||||
transform={`translate(0,${this.props.getCategoryY(cat)})`}
|
||||
>
|
||||
<line x1={dims.marginLeft} x2={dims.width - dims.width_controls} />
|
||||
</g>
|
||||
<g
|
||||
className="tick"
|
||||
opacity="1"
|
||||
transform={`translate(0,${this.props.getCategoryY(cat)})`}
|
||||
>
|
||||
<text x={dims.marginLeft - 5} dy="0.32em">
|
||||
{cat}
|
||||
</text>
|
||||
</g>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dims, categories, fallbackLabel } = this.props;
|
||||
const categoriesExist = categories && categories.length > 0;
|
||||
const renderedCategories = categoriesExist
|
||||
? this.props.categories.map((cat, idx) => this.renderCategory(cat, idx))
|
||||
: this.renderCategory(fallbackLabel, 0);
|
||||
|
||||
return (
|
||||
<g className="yAxis">
|
||||
{renderedCategories}
|
||||
<rect
|
||||
ref={this.grabRef}
|
||||
className="drag-grabber"
|
||||
x={dims.marginLeft}
|
||||
y={dims.marginTop}
|
||||
width={dims.width - dims.marginLeft - dims.width_controls}
|
||||
height={dims.contentHeight}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TimelineCategories;
|
||||
497
src/components/time/Timeline.js
Normal file
497
src/components/time/Timeline.js
Normal file
@@ -0,0 +1,497 @@
|
||||
import React from "react";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
import * as d3 from "d3";
|
||||
import hash from "object-hash";
|
||||
|
||||
import { setLoading, setNotLoading } from "../../actions";
|
||||
import * as selectors from "../../selectors";
|
||||
import copy from "../../common/data/copy.json";
|
||||
|
||||
import Header from "./atoms/Header";
|
||||
import Axis from "./Axis";
|
||||
import Clip from "./atoms/Clip";
|
||||
import Handles from "./atoms/Handles.js";
|
||||
import ZoomControls from "./atoms/ZoomControls.js";
|
||||
import Markers from "./atoms/Markers.js";
|
||||
import Events from "./atoms/Events.js";
|
||||
import Categories from "./Categories";
|
||||
|
||||
class Timeline extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.styleDatetime = this.styleDatetime.bind(this);
|
||||
this.getDatetimeX = this.getDatetimeX.bind(this);
|
||||
this.getY = this.getY.bind(this);
|
||||
this.onApplyZoom = this.onApplyZoom.bind(this);
|
||||
this.svgRef = React.createRef();
|
||||
this.state = {
|
||||
isFolded: false,
|
||||
dims: props.dimensions,
|
||||
scaleX: null,
|
||||
scaleY: null,
|
||||
timerange: [null, null], // two datetimes
|
||||
dragPos0: null,
|
||||
transitionDuration: 300,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (hash(nextProps) !== hash(this.props)) {
|
||||
this.setState({
|
||||
timerange: nextProps.app.timeline.range,
|
||||
scaleX: this.makeScaleX(),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hash(nextProps.domain.categories) !==
|
||||
hash(this.props.domain.categories) ||
|
||||
hash(nextProps.dimensions) !== hash(this.props.dimensions)
|
||||
) {
|
||||
const { trackHeight, marginTop } = nextProps.dimensions;
|
||||
this.setState({
|
||||
scaleY: this.makeScaleY(
|
||||
nextProps.domain.categories,
|
||||
trackHeight,
|
||||
marginTop
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
nextProps.dimensions.trackHeight !== this.props.dimensions.trackHeight
|
||||
) {
|
||||
this.computeDims();
|
||||
}
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
window.addEventListener("resize", () => {
|
||||
this.computeDims();
|
||||
});
|
||||
const element = document.querySelector(".timeline-wrapper");
|
||||
if (element !== null) {
|
||||
element.addEventListener("transitionend", (event) => {
|
||||
this.computeDims();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
makeScaleX() {
|
||||
return d3
|
||||
.scaleTime()
|
||||
.domain(this.state.timerange)
|
||||
.range([
|
||||
this.state.dims.marginLeft,
|
||||
this.state.dims.width - this.state.dims.width_controls,
|
||||
]);
|
||||
}
|
||||
|
||||
makeScaleY(categories, trackHeight, marginTop) {
|
||||
const { features } = this.props;
|
||||
if (features.GRAPH_NONLOCATED && features.GRAPH_NONLOCATED.categories) {
|
||||
categories = categories.filter(
|
||||
(cat) => !features.GRAPH_NONLOCATED.categories.includes(cat.id)
|
||||
);
|
||||
}
|
||||
const extraPadding = 0;
|
||||
const catHeight =
|
||||
categories.length > 2
|
||||
? trackHeight / categories.length
|
||||
: trackHeight / (categories.length + 1);
|
||||
const catsYpos = categories.map((g, i) => {
|
||||
return (i + 1) * catHeight + marginTop + extraPadding / 2;
|
||||
});
|
||||
const catMap = categories.map((c) => c.id);
|
||||
|
||||
return (cat) => {
|
||||
const idx = catMap.indexOf(cat);
|
||||
return catsYpos[idx];
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevState.timerange !== this.state.timerange) {
|
||||
this.setState({ scaleX: this.makeScaleX() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the time scale (x) extent in minutes
|
||||
*/
|
||||
getTimeScaleExtent() {
|
||||
if (!this.state.scaleX) return 0;
|
||||
const timeDomain = this.state.scaleX.domain();
|
||||
return (timeDomain[1].getTime() - timeDomain[0].getTime()) / 60000;
|
||||
}
|
||||
|
||||
onClickArrow() {
|
||||
this.setState((prevState, props) => {
|
||||
return { isFolded: !prevState.isFolded };
|
||||
});
|
||||
}
|
||||
|
||||
computeDims() {
|
||||
const dom = this.props.ui.dom.timeline;
|
||||
if (document.querySelector(`#${dom}`) !== null) {
|
||||
const boundingClient = document
|
||||
.querySelector(`#${dom}`)
|
||||
.getBoundingClientRect();
|
||||
|
||||
this.setState(
|
||||
{
|
||||
dims: {
|
||||
...this.props.dimensions,
|
||||
width: boundingClient.width,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
this.setState({ scaleX: this.makeScaleX() });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift time range by moving forward or backwards
|
||||
* @param {String} direction: 'forward' / 'backwards'
|
||||
*/
|
||||
onMoveTime(direction) {
|
||||
const extent = this.getTimeScaleExtent();
|
||||
const newCentralTime = d3.timeMinute.offset(
|
||||
this.state.scaleX.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;
|
||||
}
|
||||
|
||||
this.setState({ timerange: [domain0, domainF] }, () => {
|
||||
this.props.methods.onUpdateTimerange(this.state.timerange);
|
||||
});
|
||||
}
|
||||
|
||||
onCenterTime(newCentralTime) {
|
||||
const extent = this.getTimeScaleExtent();
|
||||
|
||||
const domain0 = d3.timeMinute.offset(newCentralTime, -extent / 2);
|
||||
const domainF = d3.timeMinute.offset(newCentralTime, +extent / 2);
|
||||
|
||||
this.setState({ timerange: [domain0, domainF] }, () => {
|
||||
this.props.methods.onUpdateTimerange(this.state.timerange);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change display of time range
|
||||
* WITHOUT updating the store, or data shown.
|
||||
* Used for updates in the middle of a transition, for performance purposes
|
||||
*/
|
||||
onSoftTimeRangeUpdate(timerange) {
|
||||
this.setState({ timerange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply zoom level to timeline
|
||||
* @param {object} zoom: zoom level from zoomLevels
|
||||
*/
|
||||
onApplyZoom(zoom) {
|
||||
const extent = this.getTimeScaleExtent();
|
||||
const newCentralTime = d3.timeMinute.offset(
|
||||
this.state.scaleX.domain()[0],
|
||||
extent / 2
|
||||
);
|
||||
const { rangeLimits } = this.props.app.timeline;
|
||||
|
||||
let newDomain0 = d3.timeMinute.offset(newCentralTime, -zoom.duration / 2);
|
||||
let newDomainF = d3.timeMinute.offset(newCentralTime, zoom.duration / 2);
|
||||
|
||||
if (rangeLimits) {
|
||||
// If the store contains absolute time limits,
|
||||
// make sure the zoom doesn't go over them
|
||||
const minDate = rangeLimits[0];
|
||||
const maxDate = 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: [newDomain0, newDomainF],
|
||||
},
|
||||
() => {
|
||||
this.props.methods.onUpdateTimerange(this.state.timerange);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toggleTransition(isTransition) {
|
||||
this.setState({ transitionDuration: isTransition ? 300 : 0 });
|
||||
}
|
||||
|
||||
/*
|
||||
* Setup drag behavior
|
||||
*/
|
||||
onDragStart() {
|
||||
d3.event.sourceEvent.stopPropagation();
|
||||
this.setState(
|
||||
{
|
||||
dragPos0: d3.event.x,
|
||||
},
|
||||
() => {
|
||||
this.toggleTransition(false);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Drag and update
|
||||
*/
|
||||
onDrag() {
|
||||
const drag0 = this.state.scaleX.invert(this.state.dragPos0).getTime();
|
||||
const dragNow = this.state.scaleX.invert(d3.event.x).getTime();
|
||||
const timeShift = (drag0 - dragNow) / 1000;
|
||||
|
||||
const { range, rangeLimits } = this.props.app.timeline;
|
||||
let newDomain0 = d3.timeSecond.offset(range[0], timeShift);
|
||||
let newDomainF = d3.timeSecond.offset(range[1], timeShift);
|
||||
|
||||
if (rangeLimits) {
|
||||
// If the store contains absolute time limits,
|
||||
// make sure the zoom doesn't go over them
|
||||
const minDate = rangeLimits[0];
|
||||
const maxDate = rangeLimits[1];
|
||||
|
||||
newDomain0 = newDomain0 < minDate ? minDate : newDomain0;
|
||||
newDomainF = newDomainF > maxDate ? maxDate : newDomainF;
|
||||
}
|
||||
|
||||
// Updates components without updating timerange
|
||||
this.onSoftTimeRangeUpdate([newDomain0, newDomainF]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop dragging and update data
|
||||
*/
|
||||
onDragEnd() {
|
||||
this.toggleTransition(true);
|
||||
this.props.methods.onUpdateTimerange(this.state.timerange);
|
||||
}
|
||||
|
||||
getDatetimeX(datetime) {
|
||||
return this.state.scaleX(datetime);
|
||||
}
|
||||
|
||||
getY(event) {
|
||||
const { features, domain } = this.props;
|
||||
const { USE_CATEGORIES, GRAPH_NONLOCATED } = features;
|
||||
const { categories } = domain;
|
||||
const categoriesExist =
|
||||
USE_CATEGORIES && categories && categories.length > 0;
|
||||
|
||||
if (!categoriesExist) {
|
||||
return this.state.dims.trackHeight / 2;
|
||||
}
|
||||
|
||||
const { category } = event;
|
||||
|
||||
if (GRAPH_NONLOCATED && GRAPH_NONLOCATED.categories.includes(category)) {
|
||||
const { project } = event;
|
||||
return (
|
||||
this.state.dims.marginTop +
|
||||
domain.projects[project].offset +
|
||||
this.props.ui.eventRadius
|
||||
);
|
||||
}
|
||||
if (!this.state.scaleY) return 0;
|
||||
|
||||
return this.state.scaleY(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines additional styles on the <circle> for each location.
|
||||
* A location consists of an array of events (see selectors). The function
|
||||
* also has full access to the domain and redux state to derive values if
|
||||
* necessary. The function should return an array, where the value at the
|
||||
* first index is a styles object for the SVG at the location, and the value
|
||||
* at the second index is an optional additional component that renders in
|
||||
* the <g/> div.
|
||||
*/
|
||||
styleDatetime(timestamp, category) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isNarrative, app } = this.props;
|
||||
let classes = `timeline-wrapper ${this.state.isFolded ? " folded" : ""}`;
|
||||
classes += app.narrative !== null ? " narrative-mode" : "";
|
||||
const { dims } = this.state;
|
||||
const foldedStyle = { bottom: this.state.isFolded ? -dims.height : 0 };
|
||||
const heightStyle = { height: dims.height };
|
||||
const extraStyle = { ...heightStyle, ...foldedStyle };
|
||||
const contentHeight = { height: dims.contentHeight };
|
||||
const { categories } = this.props.domain;
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
style={extraStyle}
|
||||
onKeyDown={this.props.onKeyDown}
|
||||
tabIndex="1"
|
||||
>
|
||||
<Header
|
||||
title={copy[this.props.app.language].timeline.info}
|
||||
from={this.state.timerange[0]}
|
||||
to={this.state.timerange[1]}
|
||||
onClick={() => {
|
||||
this.onClickArrow();
|
||||
}}
|
||||
hideInfo={isNarrative}
|
||||
/>
|
||||
<div className="timeline-content" style={heightStyle}>
|
||||
<div
|
||||
id={this.props.ui.dom.timeline}
|
||||
className="timeline"
|
||||
style={contentHeight}
|
||||
>
|
||||
<svg ref={this.svgRef} width={dims.width} style={contentHeight}>
|
||||
<Clip dims={dims} />
|
||||
<Axis
|
||||
dims={dims}
|
||||
extent={this.getTimeScaleExtent()}
|
||||
transitionDuration={this.state.transitionDuration}
|
||||
scaleX={this.state.scaleX}
|
||||
/>
|
||||
<Categories
|
||||
dims={dims}
|
||||
getCategoryY={(category) =>
|
||||
this.getY({ category, project: null })
|
||||
}
|
||||
onDragStart={() => {
|
||||
this.onDragStart();
|
||||
}}
|
||||
onDrag={() => {
|
||||
this.onDrag();
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
this.onDragEnd();
|
||||
}}
|
||||
categories={categories.map((c) => c.id)}
|
||||
features={this.props.features}
|
||||
fallbackLabel={
|
||||
copy[this.props.app.language].timeline
|
||||
.default_categories_label
|
||||
}
|
||||
/>
|
||||
<Handles
|
||||
dims={dims}
|
||||
onMoveTime={(dir) => {
|
||||
this.onMoveTime(dir);
|
||||
}}
|
||||
/>
|
||||
<ZoomControls
|
||||
extent={this.getTimeScaleExtent()}
|
||||
zoomLevels={this.props.app.timeline.zoomLevels}
|
||||
dims={dims}
|
||||
onApplyZoom={this.onApplyZoom}
|
||||
/>
|
||||
<Markers
|
||||
dims={dims}
|
||||
selected={this.props.app.selected}
|
||||
getEventX={(ev) => this.getDatetimeX(ev.datetime)}
|
||||
getEventY={this.getY}
|
||||
categories={categories}
|
||||
transitionDuration={this.state.transitionDuration}
|
||||
styles={this.props.ui.styles}
|
||||
features={this.props.features}
|
||||
eventRadius={this.props.ui.eventRadius}
|
||||
/>
|
||||
<Events
|
||||
events={this.props.domain.events}
|
||||
projects={this.props.domain.projects}
|
||||
categories={categories}
|
||||
styleDatetime={this.styleDatetime}
|
||||
narrative={this.props.app.narrative}
|
||||
getDatetimeX={this.getDatetimeX}
|
||||
getY={this.getY}
|
||||
getHighlights={(group) => {
|
||||
if (group === "None") {
|
||||
return [];
|
||||
}
|
||||
return categories.map((c) => c.group === group);
|
||||
}}
|
||||
getCategoryColor={this.props.methods.getCategoryColor}
|
||||
transitionDuration={this.state.transitionDuration}
|
||||
onSelect={this.props.methods.onSelect}
|
||||
dims={dims}
|
||||
features={this.props.features}
|
||||
setLoading={this.props.actions.setLoading}
|
||||
setNotLoading={this.props.actions.setNotLoading}
|
||||
eventRadius={this.props.ui.eventRadius}
|
||||
filterColors={this.props.ui.filterColors}
|
||||
coloringSet={this.props.app.coloringSet}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
dimensions: selectors.selectDimensions(state),
|
||||
isNarrative: !!state.app.associations.narrative,
|
||||
domain: {
|
||||
events: selectors.selectStackedEvents(state),
|
||||
projects: selectors.selectProjects(state),
|
||||
categories: ((state) => {
|
||||
const allcats = selectors.getCategories(state);
|
||||
const active = selectors.getActiveCategories(state);
|
||||
return allcats.filter((c) => active.includes(c.id));
|
||||
})(state),
|
||||
narratives: state.domain.narratives,
|
||||
},
|
||||
app: {
|
||||
selected: state.app.selected,
|
||||
language: state.app.language,
|
||||
timeline: state.app.timeline,
|
||||
narrative: state.app.associations.narrative,
|
||||
coloringSet: state.app.associations.coloringSet,
|
||||
},
|
||||
ui: {
|
||||
dom: state.ui.dom,
|
||||
styles: state.ui.style.selectedEvents,
|
||||
eventRadius: state.ui.eventRadius,
|
||||
filterColors: state.ui.coloring.colors,
|
||||
},
|
||||
features: selectors.getFeatures(state),
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({ setLoading, setNotLoading }, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Timeline);
|
||||
14
src/components/time/atoms/Clip.js
Normal file
14
src/components/time/atoms/Clip.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
const TimelineClip = ({ dims }) => (
|
||||
<clipPath id="clip">
|
||||
<rect
|
||||
x={dims.marginLeft}
|
||||
y="0"
|
||||
width={dims.width - dims.marginLeft - dims.width_controls}
|
||||
height={dims.contentHeight}
|
||||
/>
|
||||
</clipPath>
|
||||
);
|
||||
|
||||
export default TimelineClip;
|
||||
45
src/components/time/atoms/DatetimeBar.js
Normal file
45
src/components/time/atoms/DatetimeBar.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
|
||||
const DatetimeBar = ({
|
||||
highlights,
|
||||
events,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
onSelect,
|
||||
styleProps,
|
||||
extraRender,
|
||||
}) => {
|
||||
if (highlights.length === 0) {
|
||||
return (
|
||||
<rect
|
||||
onClick={onSelect}
|
||||
className="event"
|
||||
x={x}
|
||||
y={y}
|
||||
style={styleProps}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const sectionHeight = height / highlights.length;
|
||||
return (
|
||||
<>
|
||||
{highlights.map((h, idx) => (
|
||||
<rect
|
||||
onClick={onSelect}
|
||||
className="event"
|
||||
x={x}
|
||||
y={y - sectionHeight + idx * sectionHeight + sectionHeight / 2}
|
||||
style={{ ...styleProps, opacity: h ? 0.3 : 0.1 }}
|
||||
width={width}
|
||||
height={sectionHeight}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatetimeBar;
|
||||
24
src/components/time/atoms/DatetimeDot.js
Normal file
24
src/components/time/atoms/DatetimeDot.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
export default ({
|
||||
category,
|
||||
events,
|
||||
x,
|
||||
y,
|
||||
r,
|
||||
onSelect,
|
||||
styleProps,
|
||||
extraRender,
|
||||
}) => {
|
||||
if (!y) return null;
|
||||
return (
|
||||
<circle
|
||||
onClick={onSelect}
|
||||
className="event"
|
||||
cx={x}
|
||||
cy={y}
|
||||
style={styleProps}
|
||||
r={r}
|
||||
/>
|
||||
);
|
||||
};
|
||||
26
src/components/time/atoms/DatetimeSquare.js
Normal file
26
src/components/time/atoms/DatetimeSquare.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
|
||||
const DatetimeSquare = ({
|
||||
x,
|
||||
y,
|
||||
r,
|
||||
transform,
|
||||
onSelect,
|
||||
styleProps,
|
||||
extraRender,
|
||||
}) => {
|
||||
return (
|
||||
<rect
|
||||
onClick={onSelect}
|
||||
className="event"
|
||||
x={x}
|
||||
y={y - r}
|
||||
style={styleProps}
|
||||
width={r}
|
||||
height={r}
|
||||
transform={`rotate(45, ${x}, ${y})`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatetimeSquare;
|
||||
27
src/components/time/atoms/DatetimeStar.js
Normal file
27
src/components/time/atoms/DatetimeStar.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
const DatetimeStar = ({
|
||||
x,
|
||||
y,
|
||||
r,
|
||||
transform,
|
||||
onSelect,
|
||||
styleProps,
|
||||
extraRender,
|
||||
}) => {
|
||||
const s = (r * 2) / 3;
|
||||
return (
|
||||
<polygon
|
||||
onClick={onSelect}
|
||||
className="event"
|
||||
x={x}
|
||||
y={y - r}
|
||||
style={styleProps}
|
||||
points={`${x},${y + s} ${x - s},${y - s} ${x + s},${y} ${x - s},${y} ${
|
||||
x + s
|
||||
},${y - s}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatetimeStar;
|
||||
207
src/components/time/atoms/Events.js
Normal file
207
src/components/time/atoms/Events.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import React from "react";
|
||||
import DatetimeBar from "./DatetimeBar";
|
||||
import DatetimeSquare from "./DatetimeSquare";
|
||||
import DatetimeStar from "./DatetimeStar";
|
||||
import Project from "./Project";
|
||||
import ColoredMarkers from "../../atoms/ColoredMarkers";
|
||||
import {
|
||||
calcOpacity,
|
||||
getEventCategories,
|
||||
zipColorsToPercentages,
|
||||
calculateColorPercentages,
|
||||
isLatitude,
|
||||
isLongitude,
|
||||
} from "../../../common/utilities";
|
||||
|
||||
function renderDot(event, styles, props) {
|
||||
const colorPercentages = calculateColorPercentages(
|
||||
[event],
|
||||
props.coloringSet
|
||||
);
|
||||
return (
|
||||
<g
|
||||
className="timeline-event"
|
||||
onClick={props.onSelect}
|
||||
transform={`translate(${props.x}, ${props.y})`}
|
||||
>
|
||||
<ColoredMarkers
|
||||
radius={props.eventRadius}
|
||||
colorPercentMap={zipColorsToPercentages(
|
||||
props.filterColors,
|
||||
colorPercentages
|
||||
)}
|
||||
styles={{
|
||||
...styles,
|
||||
}}
|
||||
className="event"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBar(event, styles, props) {
|
||||
const fillOpacity = props.features.GRAPH_NONLOCATED
|
||||
? event.projectOffset >= 0
|
||||
? styles.opacity
|
||||
: 0.5
|
||||
: calcOpacity(1);
|
||||
|
||||
return (
|
||||
<DatetimeBar
|
||||
onSelect={props.onSelect}
|
||||
category={event.category}
|
||||
events={[event]}
|
||||
x={props.x}
|
||||
y={props.dims.marginTop}
|
||||
width={props.eventRadius / 4}
|
||||
height={props.dims.trackHeight}
|
||||
styleProps={{ ...styles, fillOpacity }}
|
||||
highlights={props.highlights}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDiamond(event, styles, props) {
|
||||
return (
|
||||
<DatetimeSquare
|
||||
onSelect={props.onSelect}
|
||||
x={props.x}
|
||||
y={props.y}
|
||||
r={1.8 * props.eventRadius}
|
||||
styleProps={styles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStar(event, styles, props) {
|
||||
return (
|
||||
<DatetimeStar
|
||||
onSelect={props.onSelect}
|
||||
x={props.x}
|
||||
y={props.y}
|
||||
r={1.8 * props.eventRadius}
|
||||
styleProps={{ ...styles, fillRule: "nonzero" }}
|
||||
transform="rotate(90)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TimelineEvents = ({
|
||||
events,
|
||||
projects,
|
||||
categories,
|
||||
narrative,
|
||||
getDatetimeX,
|
||||
getY,
|
||||
getCategoryColor,
|
||||
getHighlights,
|
||||
onSelect,
|
||||
transitionDuration,
|
||||
dims,
|
||||
features,
|
||||
setLoading,
|
||||
setNotLoading,
|
||||
eventRadius,
|
||||
filterColors,
|
||||
coloringSet,
|
||||
}) => {
|
||||
const narIds = narrative ? narrative.steps.map((s) => s.id) : [];
|
||||
|
||||
function renderEvent(acc, event) {
|
||||
if (narrative) {
|
||||
if (!narIds.includes(event.id)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const isDot =
|
||||
(isLatitude(event.latitude) && isLongitude(event.longitude)) ||
|
||||
(features.GRAPH_NONLOCATED && event.projectOffset !== -1);
|
||||
|
||||
let renderShape = isDot ? renderDot : renderBar;
|
||||
if (event.shape) {
|
||||
if (event.shape === "bar") {
|
||||
renderShape = renderBar;
|
||||
} else if (event.shape === "diamond") {
|
||||
renderShape = renderDiamond;
|
||||
} else if (event.shape === "star") {
|
||||
renderShape = renderStar;
|
||||
} else {
|
||||
renderShape = renderDot;
|
||||
}
|
||||
}
|
||||
|
||||
// if an event has multiple categories, it should be rendered on each of
|
||||
// those timelines: so we create as many event 'shadows' as there are
|
||||
// categories
|
||||
const evShadows = getEventCategories(event, categories).map((cat) => {
|
||||
const y = getY({ ...event, category: cat.id });
|
||||
|
||||
const colour = event.colour ? event.colour : getCategoryColor(cat.id);
|
||||
const styles = {
|
||||
fill: colour,
|
||||
fillOpacity: y > 0 ? calcOpacity(1) : 0,
|
||||
transition: `transform ${transitionDuration / 1000}s ease`,
|
||||
};
|
||||
|
||||
return { y, styles };
|
||||
});
|
||||
|
||||
function getRender(y, styles) {
|
||||
return renderShape(event, styles, {
|
||||
x: getDatetimeX(event.datetime),
|
||||
y,
|
||||
eventRadius,
|
||||
onSelect: () => onSelect(event),
|
||||
dims,
|
||||
highlights: features.HIGHLIGHT_GROUPS
|
||||
? getHighlights(
|
||||
event.filters[
|
||||
features.HIGHLIGHT_GROUPS.filterIndexIndicatingGroup
|
||||
]
|
||||
)
|
||||
: [],
|
||||
features,
|
||||
filterColors,
|
||||
coloringSet,
|
||||
});
|
||||
}
|
||||
|
||||
if (evShadows.length === 0) {
|
||||
acc.push(getRender(getY(event), { fill: getCategoryColor(null) }));
|
||||
} else {
|
||||
evShadows.forEach((evShadow) => {
|
||||
acc.push(getRender(evShadow.y, evShadow.styles));
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
let renderProjects = () => null;
|
||||
if (features.GRAPH_NONLOCATED) {
|
||||
renderProjects = function () {
|
||||
return (
|
||||
<>
|
||||
{Object.values(projects).map((project) => (
|
||||
<Project
|
||||
{...project}
|
||||
eventRadius={eventRadius}
|
||||
onClick={() => console.log(project)}
|
||||
getX={getDatetimeX}
|
||||
dims={dims}
|
||||
colour={getCategoryColor(project.category)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<g clipPath="url(#clip)">
|
||||
{renderProjects()}
|
||||
{events.reduce(renderEvent, [])}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineEvents;
|
||||
36
src/components/time/atoms/Handles.js
Normal file
36
src/components/time/atoms/Handles.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
const TimelineHandles = ({ dims, onMoveTime }) => {
|
||||
const transform = "scale(1.5,1.5)";
|
||||
const size = 45;
|
||||
return (
|
||||
<g className="time-controls-inline">
|
||||
<g
|
||||
transform={`translate(${dims.marginLeft - 20}, ${
|
||||
dims.contentHeight - 10
|
||||
})`}
|
||||
onClick={() => onMoveTime("backwards")}
|
||||
>
|
||||
<circle r={size} />
|
||||
<path
|
||||
d="M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z"
|
||||
transform={`rotate(270) ${transform}`}
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform={`translate(${dims.width - dims.width_controls + 20}, ${
|
||||
dims.contentHeight - 10
|
||||
})`}
|
||||
onClick={() => onMoveTime("forward")}
|
||||
>
|
||||
<circle r={size} />
|
||||
<path
|
||||
d="M0,-7.847549217020565L6.796176979388489,3.9237746085102825L-6.796176979388489,3.9237746085102825Z"
|
||||
transform={`rotate(90) ${transform}`}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineHandles;
|
||||
24
src/components/time/atoms/Header.js
Normal file
24
src/components/time/atoms/Header.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { makeNiceDate } from "../../../common/utilities";
|
||||
|
||||
const TimelineHeader = ({ title, from, to, onClick, hideInfo }) => {
|
||||
const d0 = from && makeNiceDate(from);
|
||||
const d1 = to && makeNiceDate(to);
|
||||
return (
|
||||
<div className="timeline-header">
|
||||
<div className="timeline-toggle" onClick={() => onClick()}>
|
||||
<p>
|
||||
<i className="arrow-down" />
|
||||
</p>
|
||||
</div>
|
||||
<div className={`timeline-info ${hideInfo ? "hidden" : ""}`}>
|
||||
<p>{title}</p>
|
||||
<p>
|
||||
{d0} - {d1}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineHeader;
|
||||
35
src/components/time/atoms/Labels.js
Normal file
35
src/components/time/atoms/Labels.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
const TimelineLabels = ({ dims, timelabels }) => {
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
class="axisBoundaries"
|
||||
x1={dims.marginLeft}
|
||||
x2={dims.marginLeft}
|
||||
y1="10"
|
||||
y2="20"
|
||||
/>
|
||||
<line
|
||||
class="axisBoundaries"
|
||||
x1={dims.width - dims.width_controls}
|
||||
x2={dims.width - dims.width_controls}
|
||||
y1="10"
|
||||
y2="20"
|
||||
/>
|
||||
<text class="timeLabel0 timeLabel" x="5" y="15">
|
||||
{timelabels[0]}
|
||||
</text>
|
||||
<text
|
||||
class="timelabelF timeLabel"
|
||||
x={dims.width - dims.width_controls - 5}
|
||||
y="135"
|
||||
style={{ textAnchor: "end" }}
|
||||
>
|
||||
{timelabels[1]}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineLabels;
|
||||
97
src/components/time/atoms/Markers.js
Normal file
97
src/components/time/atoms/Markers.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import colors from "../../../common/global";
|
||||
import {
|
||||
getEventCategories,
|
||||
isLatitude,
|
||||
isLongitude,
|
||||
} from "../../../common/utilities";
|
||||
|
||||
const TimelineMarkers = ({
|
||||
styles,
|
||||
eventRadius,
|
||||
getEventX,
|
||||
getEventY,
|
||||
categories,
|
||||
transitionDuration,
|
||||
selected,
|
||||
dims,
|
||||
features,
|
||||
}) => {
|
||||
function renderMarker(acc, event) {
|
||||
function renderCircle(y) {
|
||||
return (
|
||||
<circle
|
||||
className="timeline-marker"
|
||||
cx={0}
|
||||
cy={0}
|
||||
stroke={styles ? styles.stroke : colors.primaryHighlight}
|
||||
stroke-opacity="1"
|
||||
stroke-width={styles ? styles["stroke-width"] : 1}
|
||||
stroke-linejoin="round"
|
||||
stroke-dasharray={styles ? styles["stroke-dasharray"] : "2,2"}
|
||||
style={{
|
||||
transform: `translate(${getEventX(event)}px, ${y}px)`,
|
||||
"-webkit-transition": `transform ${
|
||||
transitionDuration / 1000
|
||||
}s ease`,
|
||||
"-moz-transition": "none",
|
||||
opacity: 1,
|
||||
}}
|
||||
r={eventRadius * 2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function renderBar() {
|
||||
return (
|
||||
<rect
|
||||
className="timeline-marker"
|
||||
x={0}
|
||||
y={dims.marginTop}
|
||||
width={eventRadius / 1.5}
|
||||
height={dims.contentHeight - 55}
|
||||
stroke={styles ? styles.stroke : colors.primaryHighlight}
|
||||
stroke-opacity="1"
|
||||
stroke-width={styles ? styles["stroke-width"] : 1}
|
||||
stroke-dasharray={styles ? styles["stroke-dasharray"] : "2,2"}
|
||||
style={{
|
||||
transform: `translate(${getEventX(event)}px)`,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isDot =
|
||||
(isLatitude(event.latitude) && isLongitude(event.longitude)) ||
|
||||
(features.GRAPH_NONLOCATED && event.projectOffset !== -1);
|
||||
const evShadows = getEventCategories(event, categories).map((cat) =>
|
||||
getEventY({ ...event, category: cat.id })
|
||||
);
|
||||
|
||||
function renderMarkerForEvent(y) {
|
||||
switch (event.shape) {
|
||||
case "circle":
|
||||
case "diamond":
|
||||
case "star":
|
||||
acc.push(renderCircle(y));
|
||||
break;
|
||||
case "bar":
|
||||
acc.push(renderBar(y));
|
||||
break;
|
||||
default:
|
||||
return isDot ? acc.push(renderCircle(y)) : acc.push(renderBar(y));
|
||||
}
|
||||
}
|
||||
|
||||
if (evShadows.length > 0) {
|
||||
evShadows.forEach(renderMarkerForEvent);
|
||||
} else {
|
||||
renderMarkerForEvent(getEventY(event));
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
return <g clipPath="url(#clip)">{selected.reduce(renderMarker, [])}</g>;
|
||||
};
|
||||
|
||||
export default TimelineMarkers;
|
||||
30
src/components/time/atoms/Project.js
Normal file
30
src/components/time/atoms/Project.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
const Project = ({
|
||||
offset,
|
||||
id,
|
||||
start,
|
||||
end,
|
||||
getX,
|
||||
y,
|
||||
dims,
|
||||
colour,
|
||||
eventRadius,
|
||||
onClick,
|
||||
}) => {
|
||||
const length = getX(end) - getX(start);
|
||||
if (offset === undefined) return null;
|
||||
return (
|
||||
<rect
|
||||
onClick={onClick}
|
||||
className="project"
|
||||
x={getX(start)}
|
||||
y={dims.marginTop + offset}
|
||||
width={length}
|
||||
style={{ fill: colour, fillOpacity: 0.2 }}
|
||||
height={2 * eventRadius}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Project;
|
||||
48
src/components/time/atoms/ZoomControls.js
Normal file
48
src/components/time/atoms/ZoomControls.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
|
||||
const DEFAULT_ZOOM_LEVELS = [
|
||||
{ label: "20 years", duration: 10512000 },
|
||||
{ label: "2 years", duration: 1051200 },
|
||||
{ label: "3 months", duration: 129600 },
|
||||
{ label: "3 days", duration: 4320 },
|
||||
{ label: "12 hours", duration: 720 },
|
||||
{ label: "1 hour", duration: 60 },
|
||||
];
|
||||
|
||||
function zoomIsActive(duration, extent, max) {
|
||||
if (duration >= max && extent >= max) {
|
||||
return true;
|
||||
}
|
||||
return duration === extent;
|
||||
}
|
||||
|
||||
const TimelineZoomControls = ({ extent, zoomLevels, dims, onApplyZoom }) => {
|
||||
function renderZoom(zoom, idx) {
|
||||
const max = zoomLevels.reduce((acc, vl) =>
|
||||
acc.duration < vl.duration ? vl : acc
|
||||
);
|
||||
const isActive = zoomIsActive(zoom.duration, extent, max.duration);
|
||||
return (
|
||||
<text
|
||||
className={`zoom-level-button ${isActive ? "active" : ""}`}
|
||||
x="60"
|
||||
y={idx * 15 + 20}
|
||||
onClick={() => onApplyZoom(zoom)}
|
||||
key={idx}
|
||||
>
|
||||
{zoom.label}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
if (zoomLevels.length === 0) {
|
||||
zoomLevels = DEFAULT_ZOOM_LEVELS;
|
||||
}
|
||||
return (
|
||||
<g transform={`translate(${dims.width - dims.width_controls}, 0)`}>
|
||||
{zoomLevels.map((z, idx) => renderZoom(z, idx))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineZoomControls;
|
||||
Reference in New Issue
Block a user