diff options
Diffstat (limited to 'client/src/lib/timeline')
| -rw-r--r-- | client/src/lib/timeline/index.js | 164 | ||||
| -rw-r--r-- | client/src/lib/timeline/tickMarks.js | 192 | ||||
| -rw-r--r-- | client/src/lib/timeline/timelineEvent.js | 102 | ||||
| -rw-r--r-- | client/src/lib/timeline/timelineFilter.js | 127 | ||||
| -rw-r--r-- | client/src/lib/timeline/timelineFull.js | 227 | ||||
| -rw-r--r-- | client/src/lib/timeline/timelineHeader.js | 111 |
6 files changed, 923 insertions, 0 deletions
diff --git a/client/src/lib/timeline/index.js b/client/src/lib/timeline/index.js new file mode 100644 index 0000000..3fde6df --- /dev/null +++ b/client/src/lib/timeline/index.js @@ -0,0 +1,164 @@ +import React, { Component } from 'react'; +import { + FlatList, + StyleSheet, + Text, + TouchableOpacity, + View, + ScrollView, +} from 'react-native'; + +import ScrollableContainer from '../components/scrollableContainer' +import Modal from '../components/modal' +import Container from '../components/container' +import ClearText from '../components/text' +import TimelineEvent from './timelineEvent' +import TimelineFull from './timelineFull' +import TimelineHeader from './timelineHeader' +import TimelineFilter from './timelineFilter' +import TickMarks from './tickMarks' + +export default class Timeline extends Component { + constructor(props) { + super() + this.state = { + events: props.events, + tag: props.tag, + introVisible: props.firstTime, + modalVisible: false, + filterVisible: false, + item: null, + + introVisible: false, +// modalVisible: true, +// item: props.events[2], + } + this.onPress = this.onPress.bind(this) + this.onFilter = this.onFilter.bind(this) + this.scrollToIndex = this.scrollToIndex.bind(this) + this.items = [] + } + onPress(item) { + this.setState({ + modalVisible: true, + item: item, + }) + } + onFilter(tag) { + let events; + if (!! tag && tag !== this.state.tag) { + events = this.props.events.filter((e) => e && e.keywords.includes(tag)) + } + else { + events = this.props.events + } + this.setState({ events, tag, modalVisible: false, filterVisible: false }) + } + scrollToIndex(index) { + // this.flatList._listRef.scrollToIndex(index) + // this.scrollable.scrollToIndex({index: index}) + const offset = this.items[index].ref.parentNode.offsetTop + this.scrollable.scrollView.scrollTo({ + y: offset, + x: 0, + animated: true, + }) + } + render() { + const heading = this.state.tag || 'History of Surveillance' + const items = this.state.events.map((item, i) => ( + <TimelineEvent ref={(ref) => this.items[i] = ref} key={item.id} onPress={this.onPress} item={item} /> + )) + + return ( + <View> + <ScrollableContainer ref={(ref) => this.scrollable = ref} heading={heading} headingOnPress={() => this.onFilter("")}> + {items} + </ScrollableContainer> + <TickMarks events={this.state.events} scrollToIndex={this.scrollToIndex} /> + <Modal isVisible={this.state.introVisible} style={[styles.modal, styles.headerModal]}> + <TimelineHeader + content={this.props.content} + onLinkPress={this.props.onLinkPress} + onClose={() => this.setState({ introVisible: false })} + /> + </Modal> + <Modal isVisible={this.state.modalVisible} style={styles.modal}> + <TimelineFull + item={this.state.item} + onLinkPress={this.props.onLinkPress} + onFilter={this.onFilter} + onClose={() => this.setState({ modalVisible: false })} + /> + </Modal> + </View> + ) + } +} + + + +// <View style={styles.body}> +// <View style={styles.sidebar}> +// <TouchableOpacity onPress={() => this.setState({ filterVisible: true })}> +// <ClearText style={[styles.filterText, !! this.state.tag ? styles.filterActive : styles.filterInactive ]}> +// { !! this.state.tag ? 'FILTER' : '' } +// </ClearText> +// </TouchableOpacity> +// </View> +// </View> + +// <Modal isVisible={this.state.filterVisible} style={styles.modal}> +// <TimelineFilter +// events={this.props.events} +// tag={this.state.tag} +// onFilter={this.onFilter} +// onClose={() => this.setState({ filterVisible: false })} +// /> +// </Modal> + +const styles = StyleSheet.create({ + wrapper: { + }, + modal: { + margin: 0, + padding: 0, + }, + headerModal: { + justifyContent: 'flex-start', + alignItems: 'center', + backgroundColor: 'rgb(0,0,0)', + margin: 0, + padding: 0, + }, + body: { + flexDirection: 'row', + height: 840, + }, + flatList: { + }, + container: { + flex: 1, + justifyContent: 'flex-start', + alignItems: 'center', + }, + sidebar: { + flex: 0, + }, + filterText: { + color: '#bbb', + textAlign: 'left', + fontSize: 12, + marginLeft: 4, + }, + filterActive: { + color: '#fff', + textDecorationLine: 'underline', + }, + filterInactive: { + color: '#bbb', + }, + tickMarks: { + flex: 0, + }, +}) diff --git a/client/src/lib/timeline/tickMarks.js b/client/src/lib/timeline/tickMarks.js new file mode 100644 index 0000000..c8bda29 --- /dev/null +++ b/client/src/lib/timeline/tickMarks.js @@ -0,0 +1,192 @@ +import React, { Component } from 'react' + +const isIphone = (navigator.userAgent.match(/iPhone/i)) || (navigator.userAgent.match(/iPod/i)) +const isIpad = (navigator.userAgent.match(/iPad/i)) +const isAndroid = (navigator.userAgent.match(/Android/i)) +const isMobile = isIphone || isIpad || isAndroid +const isDesktop = ! isMobile + +function getFirstTouch(f) { + return (e) => f(e.touches[0]) +} + +export default class TickMarks extends Component { + constructor(props) { + super() + + this.width = 45 + this.height = window.innerHeight - 160 + this.tag = "" + + this.onTouchStart = this.onTouchStart.bind(this) + this.onTouchMove = this.onTouchMove.bind(this) + this.onTouchEnd = this.onTouchEnd.bind(this) + } + + buildMarkers() { + this.yearTicks = [] + this.years = [] + + const height = this.height + const width = this.width + + const yPadding = fontStyle.fontSize + const heightRange = height - yPadding * 2 + + const startYear = 1800 + const endYear = 2018 + const yearStep = 10 + const yearMarkerStep = 50 + const yearDiff = endYear - startYear + + for (let year = startYear; year <= endYear; year += yearStep) { + let y = (year - startYear) / yearDiff * heightRange + yPadding + let isMarker = (year % yearMarkerStep) == 0 + let style = isMarker ? tickStyles.marker : tickStyles.year + this.yearTicks.push( + <line + key={'tick_' + year} + x1={width} + y1={y} + x2={width - style.width} + y2={y} + stroke={style.stroke} + strokeWidth={style.strokeWidth} + /> + ) + if (isMarker) { + this.years.push( + <text + key={'label_' + year} + fontFamily="Futura-Medium" + fill={fontStyle.fill} + fontSize={fontStyle.fontSize} + x={1} + y={y - fontStyle.yOffset} + textAnchor="start" + >{year}</text> + ) + } + } + this.yearsByOffset = [] + this.eventTicks = this.props.events.map((event, index) => { + if (event.date.match(/\D/)) { + return null + } + const year = parseInt(event.date) + const y = (year - startYear) / yearDiff * heightRange + yPadding + const style = tickStyles.event + if (year > 1800) { + this.yearsByOffset.push([y, Math.max(index-1, 0)]) + } + return ( + <line + key={'event_' + event.id.replace(/-/, '_')} + x1={width} + y1={y+1} + x2={width - style.width} + y2={y+1} + stroke={style.stroke} + strokeWidth={style.strokeWidth} + /> + ) + }).filter(e => !!e) + } + + scrollToYPosition(y) { + let index; + y -= this.svg.getBoundingClientRect().top + var foundOffset = this.yearsByOffset.some((pair) => { + if (y < pair[0]) { + index = pair[1] + return true + } + return false + }) + if (foundOffset) { + this.props.scrollToIndex(index) + } + } + + render() { + this.buildMarkers() + this.tag = this.props.tag + return ( + <svg + style={styles.svg} + height={this.height} + width={this.width} + ref={(ref) => this.svg = ref} + > + {this.eventTicks} + {this.yearTicks} + {this.years} + </svg> + ) + } + + componentDidMount() { + if (isMobile) { + this.svg.addEventListener("touchstart", getFirstTouch(this.onTouchStart)) + this.svg.addEventListener("touchmove", getFirstTouch(this.onTouchMove)) + window.addEventListener("touchend", getFirstTouch(this.onTouchEnd)) + } + else { + this.svg.addEventListener("mousedown", this.onTouchStart) + this.svg.addEventListener("mousemove", this.onTouchMove) + window.addEventListener("mouseup", this.onTouchEnd) + } + } + + onTouchStart(e) { + this.dragging = true + this.scrollToYPosition(e.pageY) + } + onTouchMove(e) { + if (this.dragging || isMobile) { + this.scrollToYPosition(e.pageY) + } + } + onTouchEnd(e) { + this.dragging = false + } + +} + + +const styles = { + svg: { + position: 'fixed', + top: 105, + left: '2%', + zIndex: 2, + }, +} + +const tickStyles = { + year: { + width: 4, + stroke: '#bbb', + strokeWidth: 1, + }, + marker: { + width: 10, + stroke: '#888', + strokeWidth: 1 + }, + event: { + width: 10, + stroke: 'white', + strokeWidth: 2 + }, +} +const eventColors = { + 'Surveillance': 'rgb(0,64,255)', + 'Drones': 'rgb(255,0,0)', + 'Facial Recognition': 'rgb(0,255,0)', +} +const fontStyle = { + fontSize: 12, + yOffset: -3, + fill: '#bbb', +} diff --git a/client/src/lib/timeline/timelineEvent.js b/client/src/lib/timeline/timelineEvent.js new file mode 100644 index 0000000..903ceed --- /dev/null +++ b/client/src/lib/timeline/timelineEvent.js @@ -0,0 +1,102 @@ +import React, { Component } from 'react'; +import { + TouchableOpacity, + StyleSheet, + Image, + View +} from 'react-native'; + +import ClearText from '../components/text' + +const isIphone = (navigator.userAgent.match(/iPhone/i)) || (navigator.userAgent.match(/iPod/i)) +const isIpad = (navigator.userAgent.match(/iPad/i)) +const isAndroid = (navigator.userAgent.match(/Android/i)) +const isMobile = isIphone || isIpad || isAndroid +const isDesktop = ! isMobile + +const imageHeight = isMobile ? 70 : 100 +const imageWidth = isMobile ? 100 : 150 + +export default class TimelineEvent extends Component { + constructor() { + super() + } + render() { + const item = this.props.item + let image; + if (item.image && item.image.uri) { + const originalWidth = Number(item.image.width) + const originalHeight = Number(item.image.height) + let height = originalHeight > imageHeight ? imageHeight : originalHeight + let width = originalWidth * height / originalHeight + if (width > imageWidth) { + width = imageWidth + height = originalHeight * imageWidth / originalWidth + } + if (isNaN(width) || isNaN(height)) { + console.log(width, height, item.image.uri) + } + image = <img + src={item.image.uri} + style={{ + width: width, + height: height, + }} /> + } else { + image = <View></View> + } + return ( + <TouchableOpacity style={styles.item} activeOpacity={0.8} onPress={() => this.props.onPress(this.props.item) }> + <div ref={(ref) => this.ref = ref} style={{flex: 0}}></div> + <View style={styles.item}> + <View style={styles.dateContainer}> + <ClearText style={styles.date}>{item.date}</ClearText> + </View> + <View style={styles.imageContainer}>{image}</View> + <View style={styles.titleContainer}> + <ClearText style={styles.title}>{item.title}</ClearText> + </View> + </View> + </TouchableOpacity> + ) + } +} + +const styles = StyleSheet.create({ + item: { + flex: 1, + width: '80%', + justifyContent: 'flex-start', + alignItems: 'center', + flexDirection: 'row', + padding: 10, + marginBottom: 10, + minHeight: 100, + }, + dateContainer: { + width: '30%', + justifyContent: 'flex-start', + alignItems: 'center', + paddingRight: 10, + paddingLeft: 30, + }, + imageContainer: { + width: '40%', + justifyContent: 'flex-start', + alignItems: 'center', + marginRight: 10, + }, + titleContainer: { + width: '30%', + justifyContent: 'flex-start', + alignItems: 'center', + paddingLeft: 10, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + }, + date: { + textAlign: 'center', + }, +}) diff --git a/client/src/lib/timeline/timelineFilter.js b/client/src/lib/timeline/timelineFilter.js new file mode 100644 index 0000000..c6da98a --- /dev/null +++ b/client/src/lib/timeline/timelineFilter.js @@ -0,0 +1,127 @@ +import React, { Component } from 'react'; +import { + ScrollView, + StyleSheet, + TouchableOpacity, + Image, + View, + Text, + RefreshControl, +} from 'react-native'; + +import ClearText from '../components/text' +import Heading from '../components/heading' +import Definition from '../components/definition' +import Close from '../components/close' + +export default class TimelineFilter extends Component { + constructor(props) { + super() + this.onRefresh = this.onRefresh.bind(this) + this.buildCounts(props.events) + } + buildCounts(events){ + const lookup = {} + events.forEach((event) => { + event.keywords.forEach((t) => { + lookup[t] = lookup[t] || 0 + lookup[t] += 1 + }) + }) + + this.tags = Object.keys(lookup) + .map((t) => [t, lookup[t]]) + .sort((a,b) => a[1]<b[1]?-1:a[1]==b[1]?0:1) + .filter((t) => t[1] > 1) + .reverse() + } + buildTagItems() { + const maxFontSize = 22 + const minFontSize = 12 + const fontSizeDiff = maxFontSize - minFontSize + const maxCount = this.tags[2][1] + const selectedTag = this.props.tag + + return this.tags.map((pair, i) => { + const tag = pair[0] + const count = pair[1] + const fontSize = Math.min(maxCount, count) / maxCount * (fontSizeDiff) + minFontSize + const textDecorationLine = tag === selectedTag ? 'underline' : 'none' + const color = (!! selectedTag && tag !== selectedTag) ? '#bbb' : '#fff' + + return ( + <ClearText + key={'tag_' + i} + style={[styles.link, {fontSize, padding: 10, textDecorationLine, color}]} + onPress={() => this.props.onFilter(tag)} + > + {tag} + </ClearText> + ) + }) + + } + onRefresh() { + this.props.onClose() + } + render() { + const tagItems = this.buildTagItems() + const refreshControl = ( + <RefreshControl + refreshing={false} + onRefresh={this.onRefresh} + tintColor={'rgba(0,0,0,0)'} + /> + ) + + return ( + <View style={styles.container}> + <ScrollView + style={styles.scrollView} + contentContainerStyle={styles.body} + horizontal={false} + showsHorizontalScrollIndicator={false} + refreshControl={refreshControl} + > + <Heading style={styles.heading}>CATEGORIES</Heading> + <View style={styles.tagItems}>{tagItems}</View> + </ScrollView> + <Close onPress={this.props.onClose} /> + </View> + ) + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'flex-start', + alignItems: 'flex-start', + }, + heading: { + marginLeft: 0, + }, + scrollView: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + body: { + backgroundColor: 'black', + height: '100%', + }, + tagItems: { + flexWrap: 'wrap', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'row', + padding: 5, + }, + link: { + marginHorizontal: 2, + marginVertical: 2, + lineHeight: 16, + }, +}) diff --git a/client/src/lib/timeline/timelineFull.js b/client/src/lib/timeline/timelineFull.js new file mode 100644 index 0000000..a004987 --- /dev/null +++ b/client/src/lib/timeline/timelineFull.js @@ -0,0 +1,227 @@ +import React, { Component } from 'react'; +import { + ScrollView, + StyleSheet, + TouchableOpacity, + Image, + View, + RefreshControl, +} from 'react-native'; + +import HTMLView from 'react-native-htmlview' +import htmlStyles from '../components/htmlStyles' + +import ClearText from '../components/text' +import Heading from '../components/heading' +import Definition from '../components/definition' +import Close from '../components/close' + +export default class TimelineFull extends Component { + constructor() { + super() + this.onRefresh = this.onRefresh.bind(this) + this.onPickTag = this.onPickTag.bind(this) + } + onRefresh() { + this.props.onClose() + } + onPickTag(tag) { + this.props.onFilter(tag) + } + render() { + const item = this.props.item + let image, links; + if (! item) { + return ( <View></View> ) + } + + if (item.image) { + const caption = item.credit ? ( + <ClearText style={styles.caption}>{item.credit}</ClearText> + ) : ( + <View></View> + ) + const originalWidth = Number(item.image.width) + const originalHeight = Number(item.image.height) + const height = originalHeight > 450 ? 450 : originalHeight + const width = originalWidth * height / originalHeight + image = ( + <View style={styles.imageContainer}> + <View style={styles.imageWrapper}> + <img src={item.image.uri} + style={[styles.image, { + width: width, + height: height, + }]} /> + </View> + {caption} + </View> + ) + } else { + image = ( <View></View> ) + } + + if (item.links.length) { + const linkItems = item.links.map((link, i) => { + const url = link.uri + let name = link.text + if (! name || name.match(/Link Text/i)) { + name = linkTextFromUrl(url) + } + return ( + <TouchableOpacity key={'link_' + i} onPress={() => this.props.onLinkPress(url)}> + <ClearText style={styles.link}>{name}</ClearText> + </TouchableOpacity> + ) + }) + links = ( + <Definition label='Links' contentIsView={true}>{linkItems}</Definition> + ) + } + + const tags = item.keywords.map((tag, i) => { + return `<a href='${tag}'>${tag}</a>` + }).join(', ') + + const description = '<p>' + item.description + '</p>' + + return ( + <View style={styles.container}> + <ScrollView + contentContainerStyle={styles.item} + horizontal={false} + showsHorizontalScrollIndicator={false} + > + {image} + <Heading style={styles.title}>{item.title}</Heading> + <View style={styles.contentContainer}> + <View style={styles.bodyContainer}> + <HTMLView value={description} style={styles.description} stylesheet={htmlStyles} onLinkPress={this.props.onLinkPress} /> + </View> + <View style={styles.metadataContainer}> + <Definition label='Date'>{item.date}</Definition> + <Definition label='Medium'>{item.medium}</Definition> + <Definition label='Category' contentIsView={true}> + <TouchableOpacity onPress={() => this.onPickTag(item.category)}> + <ClearText style={styles.link}>{item.category}</ClearText> + </TouchableOpacity> + </Definition> + <Definition label='Tags' contentIsView={true}> + <HTMLView value={'<p>' + tags + '</p>'} stylesheet={tagHTMLStyles} onLinkPress={this.onPickTag} /> + </Definition> + {links} + </View> + </View> + </ScrollView> + <Close onPress={this.props.onClose} /> + </View> + ) + } +} + + +function linkTextFromUrl (url) { + const url_parts = url.split('/') + const domain = url_parts[2] + const terms = domain.split('.') + const len = terms.length + let term = (len > 2 && terms[len-1].length == 2) ? terms[len-3] : terms[len-2] + if (term == 'wikipedia') { + term += url_parts[4].replace(/\#.*/,'').replace('_', ' ') + } + return capitalize(term) +} +function capitalize (s){ + return s.charAt(0).toUpperCase() + s.slice(1) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'black', + }, + item: { + maxWidth: 1000, + padding: 40, + }, + imageContainer: { + width: '100%', + marginBottom: 10, + alignItems: 'center', + }, + imageWrapper: { + width: '100%', + marginBottom: 5, + alignItems: 'center', + }, + caption: { + fontSize: 12, + }, + + bodyContainer: { + left: '0%', + width: '60%', + paddingRight: 50, + }, + metadataContainer: { + position: 'absolute', + left: '60%', + width: '40%', + }, + title: { + textAlign: 'left', + fontWeight: 'bold', + fontSize: 18, + }, + date: { + textAlign: 'left', + }, + description: { + flex: 1, + flexDirection: 'column', + }, + link: { + textDecorationLine: 'underline', + textAlign: 'left', + }, + tag: { + marginRight: 5, + }, +}) + +const tagHTMLStyles = StyleSheet.create({ + p: { + color: 'white', + fontFamily: 'Futura-Medium', + textAlign: 'justify', + fontSize: 16, + lineHeight: 30, + }, + b: { + fontFamily: 'Futura-MediumItalic', + color: 'white', + fontSize: 16, + lineHeight: 30, + }, + i: { + fontFamily: 'Futura-MediumItalic', + color: 'white', + fontSize: 16, + lineHeight: 30, + }, + a: { + color: 'white', + fontFamily: 'Futura-Medium', + textDecorationLine: 'underline', + fontSize: 16, + lineHeight: 30, + }, +}) + diff --git a/client/src/lib/timeline/timelineHeader.js b/client/src/lib/timeline/timelineHeader.js new file mode 100644 index 0000000..559ace9 --- /dev/null +++ b/client/src/lib/timeline/timelineHeader.js @@ -0,0 +1,111 @@ +import React, { Component } from 'react'; +import { + StyleSheet, + TouchableOpacity, + Text, + View, + ScrollView, +} from 'react-native'; +import HTMLView from 'react-native-htmlview' + +import htmlStyles from '../components/htmlStyles' +import Heading from '../components/heading' +import ClearText from '../components/text' +import Button from '../components/button' +import Close from '../components/close' + +export default class TimelineHeader extends Component { + constructor(props) { + super() + } + render() { + if (!! this.props.tag) { + return ( + <View style={styles.tagContainer}></View> + ) + } + else { + const body = '<p>' + this.props.content.body + '</p>' + return ( + <ScrollView contentContainerStyle={styles.container}> + <View style={styles.body}> + <Heading>HISTORY OF SURVEILLANCE</Heading> + <HTMLView value={body} stylesheet={timelineHTMLStyles} onLinkPress={this.props.onLinkPress} /> + <Button buttonStyle={styles.buttonStyle} onPress={this.props.onClose} label={'VIEW THE TIMELINE'} /> + </View> + </ScrollView> + ) + } + } +} + +const styles = StyleSheet.create({ + container: { + justifyContent: 'flex-start', + alignItems: 'center', + }, + body: { + marginTop: 20, + maxWidth: 650, + marginBottom: 10, + backgroundColor: 'black', + padding: 40, + }, + buttonStyle: { + marginTop: 40, + marginLeft: 0, + marginRight: 0, + marginBottom: 0, + }, +}) + +const timelineHTMLStyles = StyleSheet.create({ + p: { + color: 'white', + fontFamily: 'Futura-Medium', + textAlign: 'justify', + fontSize: 16, + lineHeight: 30, + }, + b: { + fontFamily: 'Futura-MediumItalic', + color: 'white', + fontSize: 16, + lineHeight: 30, + }, + i: { + color: 'white', + fontFamily: 'Futura-MediumItalic', + fontSize: 16, + lineHeight: 30, + }, + a: { + color: 'white', + fontFamily: 'Futura-Medium', + textDecorationLine: 'underline', + fontSize: 16, + lineHeight: 30, + }, + red: { + color: '#f00', + fontWeight: 'bold', + fontFamily: 'Futura-MediumItalic', + fontSize: 16, + lineHeight: 24, + }, + green: { + color: '#0f0', + fontWeight: 'bold', + fontFamily: 'Futura-MediumItalic', + fontSize: 16, + lineHeight: 24, + }, + blue: { + color: '#08f', + fontWeight: 'bold', + fontFamily: 'Futura-MediumItalic', + fontSize: 16, + lineHeight: 24, + }, +}) + |
