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:
Lachlan Kermode
2021-01-19 22:22:12 +01:00
committed by GitHub
parent 745953a435
commit e99398ceab
75 changed files with 121 additions and 745 deletions

View 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;

View 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;

View 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);

View 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;

View 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;

View 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}
/>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;