summaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rw-r--r--client/actions.js7
-rw-r--r--client/applet.js18
-rw-r--r--client/common/classifier.component.js99
-rw-r--r--client/common/common.css1
-rw-r--r--client/common/detectionBoxes.component.js15
-rw-r--r--client/common/detectionList.component.js16
-rw-r--r--client/common/gate.component.js21
-rw-r--r--client/common/header.component.js1
-rw-r--r--client/common/index.js25
-rw-r--r--client/common/loader.component.js11
-rw-r--r--client/common/sidebar.component.js18
-rw-r--r--client/common/table.component.js121
-rw-r--r--client/common/video.component.js47
-rw-r--r--client/faceSearch/faceSearch.actions.js57
-rw-r--r--client/faceSearch/faceSearch.container.js24
-rw-r--r--client/faceSearch/faceSearch.query.js93
-rw-r--r--client/faceSearch/faceSearch.reducer.js32
-rw-r--r--client/faceSearch/faceSearch.result.js115
-rw-r--r--client/faceSearch/index.js5
-rw-r--r--client/index.js121
-rw-r--r--client/map/index.js78
-rw-r--r--client/map/leaflet.bezier.js261
-rw-r--r--client/nameSearch/index.js5
-rw-r--r--client/nameSearch/nameSearch.actions.js52
-rw-r--r--client/nameSearch/nameSearch.container.js24
-rw-r--r--client/nameSearch/nameSearch.query.js48
-rw-r--r--client/nameSearch/nameSearch.reducer.js32
-rw-r--r--client/nameSearch/nameSearch.result.js88
-rw-r--r--client/session.js5
-rw-r--r--client/store.js30
-rw-r--r--client/tables.js81
-rw-r--r--client/types.js17
-rw-r--r--client/util.js107
33 files changed, 1675 insertions, 0 deletions
diff --git a/client/actions.js b/client/actions.js
new file mode 100644
index 00000000..2be8229d
--- /dev/null
+++ b/client/actions.js
@@ -0,0 +1,7 @@
+import * as faceSearch from './faceSearch/faceSearch.actions'
+import * as nameSearch from './nameSearch/nameSearch.actions'
+
+export {
+ faceSearch,
+ nameSearch,
+}
diff --git a/client/applet.js b/client/applet.js
new file mode 100644
index 00000000..80d40657
--- /dev/null
+++ b/client/applet.js
@@ -0,0 +1,18 @@
+import React, { Component } from 'react'
+
+import { Container as FaceSearchContainer } from './faceSearch'
+import { Container as NameSearchContainer } from './nameSearch'
+
+export default class Applet extends Component {
+ render() {
+ // console.log(this.props)
+ switch (this.props.payload.cmd) {
+ case 'face_search':
+ return <FaceSearchContainer {...this.props} />
+ case 'name_search':
+ return <NameSearchContainer {...this.props} />
+ default:
+ return <pre style={{ color: '#0f0' }}>{'Megapixels'}</pre>
+ }
+ }
+}
diff --git a/client/common/classifier.component.js b/client/common/classifier.component.js
new file mode 100644
index 00000000..af6a4934
--- /dev/null
+++ b/client/common/classifier.component.js
@@ -0,0 +1,99 @@
+import React, { Component } from 'react'
+import { courtesyS } from '../util'
+
+import { TableTuples, DetectionList, Keyframe } from '.'
+
+export default class Classifier extends Component {
+ render() {
+ const {
+ tag,
+ sha256,
+ verified,
+ keyframes = {},
+ labels,
+ summary,
+ aspectRatio = 1.777,
+ showAll,
+ } = this.props
+ let totalDetections = 0
+ const keys = Object
+ .keys(keyframes)
+ .map(s => parseInt(s, 10))
+ const validKeyframes = keys
+ .sort((a, b) => a - b)
+ .map(frame => {
+ const detections = keyframes[frame]
+ if (detections.length || showAll) {
+ totalDetections += detections.length
+ return { frame, detections }
+ }
+ return null
+ })
+ .filter(f => !!f)
+ const detectionLookup = validKeyframes
+ .reduce((a, b) => {
+ b.detections.reduce((aa, { idx }) => {
+ if (!(idx in aa)) aa[idx] = [labels[idx], 0]
+ aa[idx][1] += 1
+ return aa
+ }, a)
+ return a
+ }, {})
+ const detectionCounts = Object.keys(detectionLookup)
+ .map(idx => detectionLookup[idx])
+ .sort((a, b) => b[1] - a[1])
+
+ if (summary) {
+ return (
+ <div>
+ <h3>{tag}{' Detections'}</h3>
+ <TableTuples
+ list={detectionCounts}
+ />
+ </div>
+ )
+ }
+ return (
+ <div>
+ <h2>{tag}</h2>
+ <h3>Detections</h3>
+ <TableTuples
+ list={detectionCounts}
+ />
+ <h3>Frames</h3>
+ <ul className='meta'>
+ <li>
+ {'Displaying '}{validKeyframes.length}{' / '}{courtesyS(keys.length, 'frame')}
+ </li>
+ <li>
+ {courtesyS(totalDetections, 'detection')}{' found'}
+ </li>
+ </ul>
+ <div className='thumbnails'>
+ {validKeyframes.map(({ frame, detections }) => (
+ <Keyframe
+ key={frame}
+ sha256={sha256}
+ frame={frame}
+ verified={verified}
+ size='th'
+ showFrame
+ showTimestamp
+ aspectRatio={aspectRatio}
+ detectionList={[
+ { labels, detections }
+ ]}
+ >
+ <DetectionList
+ labels={labels}
+ detections={detections}
+ width={160}
+ height={90}
+ />
+ </Keyframe>
+ ))}
+ </div>
+ </div>
+ )
+ }
+}
diff --git a/client/common/common.css b/client/common/common.css
new file mode 100644
index 00000000..56cf2fe9
--- /dev/null
+++ b/client/common/common.css
@@ -0,0 +1 @@
+* {} \ No newline at end of file
diff --git a/client/common/detectionBoxes.component.js b/client/common/detectionBoxes.component.js
new file mode 100644
index 00000000..c4872ea8
--- /dev/null
+++ b/client/common/detectionBoxes.component.js
@@ -0,0 +1,15 @@
+import React from 'react'
+
+import { px } from '../util'
+
+export default function DetectionBoxes({ detections, width, height }) {
+ return detections.map(({ rect }, i) => (
+ rect &&
+ <div className='rect' key={i} style={{
+ left: px(rect[0], width),
+ top: px(rect[1], height),
+ width: px(rect[2] - rect[0], width),
+ height: px(rect[3] - rect[1], height),
+ }} />
+ ))
+}
diff --git a/client/common/detectionList.component.js b/client/common/detectionList.component.js
new file mode 100644
index 00000000..416e66d8
--- /dev/null
+++ b/client/common/detectionList.component.js
@@ -0,0 +1,16 @@
+import React from 'react'
+
+export default function DetectionList({ detections, labels, tag, showEmpty }) {
+ return (
+ <span className='detectionList'>
+ {tag && <h3>{tag}</h3>}
+ {!detections.length && showEmpty && <label><small>No detections</small></label>}
+ {detections.map(({ idx, score, rect }, i) => (
+ <label key={i}>
+ <small className='title'>{(labels[idx] || 'Unknown').replace(/_/, ' ')}</small>
+ <small>{score.toFixed(2)}</small>
+ </label>
+ ))}
+ </span>
+ )
+}
diff --git a/client/common/gate.component.js b/client/common/gate.component.js
new file mode 100644
index 00000000..9bf9287b
--- /dev/null
+++ b/client/common/gate.component.js
@@ -0,0 +1,21 @@
+import React from 'react'
+import { connect } from 'react-redux'
+
+function Gate(props) {
+ const { app, tag, View } = props
+ const data = app[tag]
+ if (!data) return null
+ if (data === 'loading') {
+ return <div className='tableObject loading'>{tag}{': Loading'}</div>
+ }
+ if (data.err) {
+ return <div className='tableObject error'>{tag}{' Error: '}{data.err}</div>
+ }
+ return <View data={data} {...props} />
+}
+
+const mapStateToProps = state => ({
+ app: state.metadata
+})
+
+export default connect(mapStateToProps)(Gate)
diff --git a/client/common/header.component.js b/client/common/header.component.js
new file mode 100644
index 00000000..84fe306f
--- /dev/null
+++ b/client/common/header.component.js
@@ -0,0 +1 @@
+/* imported from main vcat application */
diff --git a/client/common/index.js b/client/common/index.js
new file mode 100644
index 00000000..cfb34b32
--- /dev/null
+++ b/client/common/index.js
@@ -0,0 +1,25 @@
+import Classifier from './classifier.component'
+import DetectionBoxes from './detectionBoxes.component'
+import DetectionList from './detectionList.component'
+// import Header from './header.component'
+import Loader from './loader.component'
+import Sidebar from './sidebar.component'
+import Gate from './gate.component'
+import Video from './video.component'
+import { TableObject, TableArray, TableTuples, TableRow, TableCell } from './table.component'
+import './common.css'
+
+export {
+ Sidebar,
+ Loader,
+ Gate,
+ TableObject,
+ TableArray,
+ TableTuples,
+ TableRow,
+ TableCell,
+ Classifier,
+ DetectionList,
+ DetectionBoxes,
+ Video,
+}
diff --git a/client/common/loader.component.js b/client/common/loader.component.js
new file mode 100644
index 00000000..5930c63e
--- /dev/null
+++ b/client/common/loader.component.js
@@ -0,0 +1,11 @@
+import React from 'react'
+
+export default function Loader() {
+ return (
+ <div className='loaderWrapper'>
+ <div className='loader'>
+ <img src="/assets/img/loader.gif" />
+ </div>
+ </div>
+ )
+}
diff --git a/client/common/sidebar.component.js b/client/common/sidebar.component.js
new file mode 100644
index 00000000..afbf8c8c
--- /dev/null
+++ b/client/common/sidebar.component.js
@@ -0,0 +1,18 @@
+import React, { Component } from 'react'
+import { connect } from 'react-redux'
+
+class Sidebar extends Component {
+ render() {
+ return (
+ <div className="sidebar">
+ </div>
+ )
+ // <NavLink to={'/metadata/' + hash + '/summary/'}>Summary</NavLink>
+ }
+}
+
+const mapStateToProps = state => ({
+ hash: state.metadata.hash,
+})
+
+export default connect(mapStateToProps)(Sidebar)
diff --git a/client/common/table.component.js b/client/common/table.component.js
new file mode 100644
index 00000000..76a1d57c
--- /dev/null
+++ b/client/common/table.component.js
@@ -0,0 +1,121 @@
+import React from 'react'
+
+import { formatName } from '../util'
+
+const __HR__ = '__HR__'
+
+export function TableObject({ tag, object, order, summary }) {
+ if (!object) return null
+ if (object === 'loading') {
+ return <div className='tableObject loading'>{tag}{': Loading'}</div>
+ }
+ if (object.err) {
+ return <div className='tableObject error'>{tag}{' Error: '}{object.err}</div>
+ }
+ let objects = Object.keys(object)
+ if (order) {
+ const grouped = objects.reduce((a, b) => {
+ const index = order.indexOf(b)
+ if (index !== -1) {
+ a.order.push([index, b])
+ } else {
+ a.alpha.push(b)
+ }
+ return a
+ }, { order: [], alpha: [] })
+ objects = grouped.order
+ .sort((a, b) => a[0] - b[0])
+ .map(([i, s]) => s)
+ if (!summary) {
+ objects = objects
+ // .concat([__HR__])
+ .concat(grouped.alpha.sort())
+ }
+ } else {
+ objects = objects.sort()
+ }
+ return (
+ <div>
+ {tag && <h3>{tag}</h3>}
+ <table className={'tableObject ' + tag}>
+ <tbody>
+ {objects.map((key, i) => (
+ <TableRow key={key + '_' + i} name={key} value={object[key]} />
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )
+}
+
+export function TableArray({ tag, list }) {
+ if (!list) return null
+ return (
+ <div>
+ {tag && <h3>{tag}</h3>}
+ <table className={'tableArray ' + tag}>
+ <tbody>
+ {list.map((value, i) => (
+ <tr key={tag + '_' + i}>
+ <TableCell value={value} />
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )
+}
+
+export function TableTuples({ tag, list }) {
+ if (!list) return null
+ return (
+ <div>
+ {tag && <h3>{tag}</h3>}
+ <table className={'tableTuples ' + tag}>
+ <tbody>
+ {list.map(([key, ...values], i) => (
+ <tr key={tag + '_' + i}>
+ <th>{formatName(key)}</th>
+ {values.map((value, j) => (
+ <TableCell key={i + '_' + j} value={value} />
+ ))}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )
+}
+
+export function TableRow({ name, value }) {
+ if (name === __HR__) {
+ return (
+ <tr>
+ <th className='tr'>
+ <hr />
+ </th>
+ </tr>
+ )
+ }
+ return (
+ <tr>
+ <th>{formatName(name)}</th>
+ <TableCell name={name} value={value} />
+ </tr>
+ )
+}
+
+export function TableCell({ value }) {
+ if (value && typeof value === 'object') {
+ if (value._raw) {
+ value = value.value
+ } else if (value.length) {
+ value = <TableArray nested tag={''} list={value} />
+ } else {
+ value = <TableObject nested tag={''} object={value} />
+ }
+ }
+ return (
+ <td>{value}</td>
+ )
+}
diff --git a/client/common/video.component.js b/client/common/video.component.js
new file mode 100644
index 00000000..e5525bf6
--- /dev/null
+++ b/client/common/video.component.js
@@ -0,0 +1,47 @@
+import React, { Component } from 'react'
+import { connect } from 'react-redux'
+import { imageUrl, widths } from '../util'
+
+import { Gate } from '.'
+
+class Video extends Component {
+ state = {
+ playing: false,
+ }
+
+ render() {
+ const { app, data, size } = this.props
+ const { playing } = this.state
+ const { sugarcube } = data.metadata
+ const url = sugarcube.fp.replace('/var/www/files/', 'https://cube.syrianarchive.org/')
+ const { sha256, verified } = app.mediainfo
+ const { video } = app.mediainfo.metadata.mediainfo
+ const keyframe = app.keyframe.metadata.keyframe.basic[0]
+ return (
+ <div className='video'>
+ {playing
+ ? <video src={url} autoPlay controls muted />
+ : <div
+ className='bg'
+ style={{
+ width: widths[size || 'sm'],
+ height: widths[size || 'sm'] / video.aspect_ratio,
+ backgroundImage: 'url(' + imageUrl(verified, sha256, keyframe, size) + ')',
+ }}
+ onClick={() => this.setState({ playing: true })}
+ >
+ <div className='play'></div>
+ </div>
+ }
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = () => ({
+ tag: 'sugarcube',
+})
+
+export default connect(mapStateToProps)(props => (
+ <Gate View={Video} {...props} />
+))
diff --git a/client/faceSearch/faceSearch.actions.js b/client/faceSearch/faceSearch.actions.js
new file mode 100644
index 00000000..03e1a91d
--- /dev/null
+++ b/client/faceSearch/faceSearch.actions.js
@@ -0,0 +1,57 @@
+// import fetchJsonp from 'fetch-jsonp'
+import * as types from '../types'
+// import { hashPath } from '../util'
+import { store } from '../store'
+import { post, preloadImage } from '../util'
+// import querystring from 'query-string'
+
+// urls
+
+const url = {
+ upload: (dataset) => process.env.API_HOST + '/api/dataset/' + dataset + '/face',
+}
+export const publicUrl = {
+}
+
+// standard loading events
+
+const loading = (tag, offset) => ({
+ type: types.faceSearch.loading,
+ tag,
+ offset
+})
+const loaded = (tag, data, offset = 0) => ({
+ type: types.faceSearch.loaded,
+ tag,
+ data,
+ offset
+})
+const error = (tag, err) => ({
+ type: types.faceSearch.error,
+ tag,
+ err
+})
+
+// search UI functions
+
+export const updateOptions = opt => dispatch => {
+ dispatch({ type: types.faceSearch.update_options, opt })
+}
+
+// API functions
+
+export const upload = (payload, file) => dispatch => {
+ // const { options } = store.getState().faceSearch
+ const tag = 'result'
+ const fd = new FormData()
+ fd.append('query_img', file)
+ // fd.append('limit', options.perPage)
+ // if (!query) {
+ dispatch(loading(tag))
+ // }
+ post(url.upload(payload.dataset), fd)
+ .then(data => {
+ dispatch(loaded(tag, data))
+ })
+ .catch(err => dispatch(error(tag, err)))
+}
diff --git a/client/faceSearch/faceSearch.container.js b/client/faceSearch/faceSearch.container.js
new file mode 100644
index 00000000..94c6eb9f
--- /dev/null
+++ b/client/faceSearch/faceSearch.container.js
@@ -0,0 +1,24 @@
+import React, { Component } from 'react'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import * as actions from './faceSearch.actions'
+
+import FaceSearchQuery from './faceSearch.query'
+import FaceSearchResult from './faceSearch.result'
+
+class FaceSearchContainer extends Component {
+ render() {
+ const { payload } = this.props
+ // console.log(payload)
+ return (
+ <div className='searchContainer'>
+ <FaceSearchQuery payload={payload} />
+ <FaceSearchResult payload={payload} />
+ </div>
+ )
+ }
+}
+
+
+export default FaceSearchContainer
diff --git a/client/faceSearch/faceSearch.query.js b/client/faceSearch/faceSearch.query.js
new file mode 100644
index 00000000..425cb282
--- /dev/null
+++ b/client/faceSearch/faceSearch.query.js
@@ -0,0 +1,93 @@
+import React, { Component } from 'react'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import { Loader } from '../common'
+import * as actions from './faceSearch.actions'
+
+class FaceSearchQuery extends Component {
+ state = {
+ image: null
+ }
+
+ upload(e) {
+ const { payload } = this.props
+ const files = e.dataTransfer ? e.dataTransfer.files : e.target.files
+ let i
+ let file
+ for (i = 0; i < files.length; i++) {
+ file = files[i]
+ if (file && file.type.match('image.*')) break
+ }
+ if (!file) return
+ const fr = new FileReader()
+ fr.onload = () => {
+ fr.onload = null
+ this.setState({ image: fr.result })
+ }
+ fr.readAsDataURL(files[0])
+ this.props.actions.upload(this.props.payload, file)
+ }
+
+ render() {
+ const { result } = this.props
+ const { image } = this.state
+ const style = {}
+ if (image) {
+ style.backgroundImage = 'url(' + image + ')'
+ style.backgroundSize = 'cover'
+ style.opacity = 1
+ }
+ return (
+ <div className='query row'>
+ <div className='uploadContainer'>
+ {result.loading ?
+ <div className='loading' style={style}>
+ <Loader />
+ </div>
+ : <div style={style}>
+ {image ? null : <img src="/assets/img/icon_camera.svg" />}
+ <input
+ type="file"
+ name="img"
+ accept="image/*"
+ onChange={this.upload.bind(this)}
+ required
+ />
+ </div>
+ }
+ </div>
+ <div className='cta'>
+ <h2>Search This Dataset</h2>
+ <h3>Searching {13456} images</h3>
+ <p>
+ {'Use facial recognition to reverse search into the LFW dataset '}
+ {'and see if it contains your photos.'}
+ </p>
+ <ol>
+ <li>Upload a photo of yourself</li>
+ <li>Use a photo similar to examples below</li>
+ <li>Only matches over 85% will be displayed</li>
+ <li>Read more tips to improve search results</li>
+ <li>{'Your search data is never stored and immediately cleared '}
+ {'once you leave this page.'}</li>
+ </ol>
+ <p>
+ Read more about <a href='/about/privacy/'>privacy</a>.
+ </p>
+ </div>
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ result: state.faceSearch.result,
+ options: state.faceSearch.options,
+})
+
+const mapDispatchToProps = dispatch => ({
+ actions: bindActionCreators({ ...actions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(FaceSearchQuery)
diff --git a/client/faceSearch/faceSearch.reducer.js b/client/faceSearch/faceSearch.reducer.js
new file mode 100644
index 00000000..da8bd25e
--- /dev/null
+++ b/client/faceSearch/faceSearch.reducer.js
@@ -0,0 +1,32 @@
+import * as types from '../types'
+
+const initialState = () => ({
+ query: {},
+ result: {},
+ loading: false,
+})
+
+export default function faceSearchReducer(state = initialState(), action) {
+ switch (action.type) {
+ case types.faceSearch.loading:
+ return {
+ ...state,
+ [action.tag]: { loading: true },
+ }
+
+ case types.faceSearch.loaded:
+ return {
+ ...state,
+ [action.tag]: action.data,
+ }
+
+ case types.faceSearch.error:
+ return {
+ ...state,
+ [action.tag]: { error: action.err },
+ }
+
+ default:
+ return state
+ }
+}
diff --git a/client/faceSearch/faceSearch.result.js b/client/faceSearch/faceSearch.result.js
new file mode 100644
index 00000000..936bc8d2
--- /dev/null
+++ b/client/faceSearch/faceSearch.result.js
@@ -0,0 +1,115 @@
+import React, { Component } from 'react'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import { courtesyS } from '../util'
+
+import * as actions from './faceSearch.actions'
+import { Loader } from '../common'
+
+const errors = {
+ bbox: (
+ <div>
+ <h2>No face found</h2>
+ {"Sorry, we didn't detect a face in that image. "}
+ {"Please choose an image where the face is large and clear."}
+ </div>
+ ),
+ nomatch: (
+ <div>
+ <h2>{"You're clear"}</h2>
+ {"No images in this dataset match your face. We show only matches above 70% probability."}
+ </div>
+ ),
+ error: (
+ <div>
+ <h2>{"No matches found"}</h2>
+ {"Sorry, an error occured."}
+ </div>
+ ),
+}
+
+class FaceSearchResult extends Component {
+ render() {
+ const { dataset } = this.props.payload
+ const { query, distances, results, loading, error } = this.props.result
+ console.log(this.props.result)
+ if (loading) {
+ return (
+ <div className='result'>
+ <div>
+ <Loader /><br />
+ <h2>Searching...</h2>
+ </div>
+ </div>
+ )
+ }
+ if (error) {
+ // console.log(error)
+ let errorMessage = errors[error] || errors.error
+ return (
+ <div className='result'>{errorMessage}</div>
+ )
+ }
+ if (!results) {
+ return <div className='result'></div>
+ }
+ if (!results.length) {
+ return (
+ <div className='result'>{errors.nomatch}</div>
+ )
+ }
+ const els = results.map((result, i) => {
+ const distance = distances[i]
+ const { uuid } = result.uuid
+ const { x, y, w, h } = result.roi
+ const { fullname, gender, description, images } = result.identity
+ const bbox = {
+ left: (100 * x) + '%',
+ top: (100 * y) + '%',
+ width: (100 * w) + '%',
+ height: (100 * h) + '%',
+ }
+ // console.log(bbox)
+ return (
+ <div key={i}>
+ <div className='img'>
+ <img src={'https://megapixels.nyc3.digitaloceanspaces.com/v1/media/' + dataset + '/' + uuid + '.jpg'} />
+ <div className='bbox' style={bbox} />
+ </div>
+ {fullname} {'('}{gender}{')'}<br/>
+ {description}<br/>
+ {courtesyS(images, 'image')}{' in dataset'}<br />
+ {Math.round((1 - distance) * 100)}{'% match'}
+ </div>
+ )
+ })
+
+ return (
+ <div className='result'>
+ <div className="about">
+ <h2>Did we find you?</h2>
+ {'These faces matched images in the '}
+ <b><tt>{dataset}</tt></b>
+ {' dataset with over 70% probability.'}
+ <br />
+ <small>Query took {query.timing.toFixed(2)} seconds</small>
+ </div>
+ <div className='results'>
+ {els}
+ </div>
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ query: state.faceSearch.query,
+ result: state.faceSearch.result,
+ options: state.faceSearch.options,
+})
+
+const mapDispatchToProps = dispatch => ({
+ actions: bindActionCreators({ ...actions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(FaceSearchResult)
diff --git a/client/faceSearch/index.js b/client/faceSearch/index.js
new file mode 100644
index 00000000..32f6dcc6
--- /dev/null
+++ b/client/faceSearch/index.js
@@ -0,0 +1,5 @@
+import Container from './faceSearch.container'
+
+export {
+ Container,
+}
diff --git a/client/index.js b/client/index.js
new file mode 100644
index 00000000..93341a77
--- /dev/null
+++ b/client/index.js
@@ -0,0 +1,121 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import { AppContainer } from 'react-hot-loader'
+import { Provider } from 'react-redux'
+
+import { toArray } from './util'
+import Applet from './applet'
+import { store } from './store'
+import appendTable from './tables'
+import appendMap from './map'
+
+function appendReactApplet(el, payload) {
+ ReactDOM.render(
+ <AppContainer>
+ <Provider store={store}>
+ <Applet payload={payload} />
+ </Provider>
+ </AppContainer>, el
+ )
+}
+
+function fetchDataset(payload) {
+ const url = "https://megapixels.nyc3.digitaloceanspaces.com/v1/citations/" + payload.dataset + ".json"
+ return fetch(url, { mode: 'cors' }).then(r => r.json())
+}
+
+function appendApplets(applets) {
+ applets.forEach(([el, payload]) => {
+ switch (payload.cmd) {
+ case 'citations':
+ case 'load_file':
+ appendTable(el, payload)
+ break
+ case 'map':
+ el.parentNode.classList.add('wide')
+ el.classList.add(payload.cmd)
+ appendMap(el, payload)
+ el.classList.add('loaded')
+ break
+ default:
+ appendReactApplet(el, payload)
+ el.classList.add('loaded')
+ break
+ }
+ })
+}
+
+function runApplets() {
+ const applets = toArray(document.querySelectorAll('.applet')).map(el => {
+ // console.log(el.dataset.payload)
+ let payload
+ try {
+ payload = JSON.parse(el.dataset.payload)
+ console.log(payload)
+ } catch (e) {
+ return null
+ }
+ let cmdPartz = payload.command.split(" ")
+ let cmd = cmdPartz.shift()
+ let dataset = null
+ let url = null
+ let opt = null
+ payload.cmd = cmd
+ payload.partz = cmdPartz
+ if (payload.partz.length) {
+ opt = payload.partz.shift().trim()
+ if (opt.indexOf('http') === 0) {
+ dataset = null
+ url = opt
+ } else if (opt.indexOf('assets') === 0) {
+ let pathname = window.location.pathname.replace('index.html', '')
+ url = 'https://nyc3.digitaloceanspaces.com/megapixels/v1' + pathname + opt
+ dataset = null
+ // console.log(url)
+ } else {
+ dataset = opt
+ url = null
+ }
+ }
+ if (!dataset && !url) {
+ const path = window.location.pathname.split('/').filter(s => !!s)
+ if (path.length) {
+ dataset = path.pop()
+ if (dataset === 'index.html') {
+ dataset = path.pop()
+ }
+ // console.log('dataset from path:', dataset)
+ } else {
+ console.log('couldnt determine citations dataset')
+ return null
+ }
+ }
+ payload.dataset = dataset
+ payload.url = url
+ return [el, payload]
+ }).filter(a => !!a)
+ const withDataset = applets.map(a => a[1].dataset ? a[1] : null).filter(a => !!a)
+ if (withDataset.length) {
+ fetchDataset(withDataset[0]).then(data => {
+ withDataset.forEach(dataset => dataset.data = data)
+ appendApplets(applets)
+ })
+ } else {
+ appendApplets(applets)
+ }
+}
+
+function main() {
+ const paras = document.querySelectorAll('section p')
+ if (paras.length) {
+ paras[0].classList.add('first_paragraph')
+ }
+ toArray(document.querySelectorAll('header .links a')).forEach(tag => {
+ if (window.location.href.match(tag.href)) {
+ tag.classList.add('active')
+ }
+ })
+ runApplets()
+}
+
+main()
diff --git a/client/map/index.js b/client/map/index.js
new file mode 100644
index 00000000..053cf13b
--- /dev/null
+++ b/client/map/index.js
@@ -0,0 +1,78 @@
+import L from 'leaflet'
+import './leaflet.bezier'
+
+function getCitations(dataset) {
+ // console.log(dataset.citations)
+ return dataset.citations.map(c => ({
+ title: c[0],
+ location: c[2],
+ lat: c[5],
+ lng: c[6],
+ type: c[7],
+ }))
+}
+
+const arcStyle = {
+ color: 'rgb(245, 246, 150)',
+ fillColor: 'rgb(245, 246, 150)',
+ opacity: 0.8,
+ weight: '1',
+}
+
+const redDot = L.icon({
+ iconUrl: '/assets/img/reddot.png',
+ iconSize: [17, 17], // size of the icon
+ iconAnchor: [8, 8], // point of the icon which will correspond to marker's location
+ popupAnchor: [0, -5] // point from which the popup should open relative to the iconAnchor
+})
+
+function addMarker(map, latlng, title, subtext) {
+ const marker = L.marker(latlng, { icon: redDot }).addTo(map)
+ marker.bindPopup([
+ "<b>", title, "</b>",
+ "<br>",
+ subtext,
+ ].join(''))
+}
+
+function addArc(map, src, dest) {
+ L.bezier({
+ path: [
+ [
+ { lat: src[0], lng: src[1] },
+ { lat: dest[0], lng: dest[1] },
+ ],
+ ]
+ }, arcStyle).addTo(map)
+}
+
+export default function append(el, payload) {
+ const { data } = payload
+ let { paper, address } = data
+ let source = [0, 0]
+ const citations = getCitations(data)
+
+ let map = L.map(el).setView([25, 0], 2)
+ L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', {
+ attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, ' +
+ '<a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, ' +
+ 'Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
+ maxZoom: 18,
+ id: 'mapbox.dark',
+ style: 'mapbox://styles/mapbox/dark-v9',
+ accessToken: 'pk.eyJ1IjoiZmFuc2FsY3kiLCJhIjoiY2pvN3I1czJwMHF5NDNrbWRoMWpteHlrdCJ9.kMpM5syQUhVjKkn1iVx9fg'
+ }).addTo(map)
+
+ if (address) {
+ source = address.slice(3, 5).map(n => parseFloat(n))
+ }
+
+ citations.forEach(point => {
+ const latlng = [point.lat, point.lng]
+ if (Number.isNaN(latlng[0]) || Number.isNaN(latlng[1])) return
+ addMarker(map, latlng, point.title, point.location)
+ addArc(map, source, latlng)
+ })
+
+ addMarker(map, source, paper.title, paper.address)
+}
diff --git a/client/map/leaflet.bezier.js b/client/map/leaflet.bezier.js
new file mode 100644
index 00000000..02adbe7f
--- /dev/null
+++ b/client/map/leaflet.bezier.js
@@ -0,0 +1,261 @@
+import L from 'leaflet'
+
+L.SVG.include({
+ _updatecurve: function (layer) {
+ let svg_path = this._curvePointsToPath(layer._points);
+ this._setPath(layer, svg_path);
+
+ if (layer.options.animate) {
+ let path = layer._path;
+ let length = path.getTotalLength();
+
+ if (!layer.options.dashArray) {
+ path.style.strokeDasharray = length + ' ' + length;
+ }
+
+ if (layer._initialUpdate) {
+ path.animate([
+ {strokeDashoffset: length},
+ {strokeDashoffset: 0}
+ ], layer.options.animate);
+ layer._initialUpdate = false;
+ }
+ }
+
+ return svg_path;
+ },
+
+
+ _curvePointsToPath: function (points) {
+ let point, curCommand, str = '';
+ for (let i = 0; i < points.length; i++) {
+ point = points[i];
+ if (typeof point === 'string' || point instanceof String) {
+ curCommand = point;
+ str += curCommand;
+ } else
+ str += point.x + ',' + point.y + ' ';
+
+
+ }
+ return str || 'M0 0';
+ },
+
+});
+
+let Bezier = L.Path.extend({
+ options: {},
+ initialize: function (path, icon, options) {
+
+ if (!path.mid || path.mid[0] === undefined) {
+ path.mid = this.getMidPoint(path.from, path.to, (path.from.deep ? path.from.deep : 4), path.from.slide);
+ }
+
+ L.setOptions(this, options);
+ this._initialUpdate = true;
+ this.setPath(path);
+ this.icon = icon;
+
+ },
+ //Juast after path is added
+ onAdd: function (map) {
+ this._renderer._initPath(this);
+ this._reset();
+ this._renderer._addPath(this);
+
+ // TODO ajust plane acording to zoom
+ map.on('zoom', function(){
+
+ });
+
+ },
+ // setAnimatePlane: function(path) {
+
+ // if (this.spaceship_img)
+ // this.spaceship_img.remove();
+
+ // let SnapSvg = Snap('.leaflet-overlay-pane>svg');
+
+ // let spaceship_img = this.spaceship_img = SnapSvg.image(this.icon.path).attr({
+ // visibility: "hidden"
+ // });
+
+
+ // let spaceship = SnapSvg.group(spaceship_img);
+ // let flight_path = SnapSvg.path(path).attr({
+ // 'fill': 'none',
+ // 'stroke': 'none'
+ // });
+
+ // let full_path_length = Snap.path.getTotalLength(flight_path);
+ // let half_path_length = full_path_length / 2;
+ // let third_path_length = full_path_length / 3;
+ // let forth_path_length = full_path_length / 4;
+
+
+ // let width = forth_path_length / this._map.getZoom();
+ // let height = forth_path_length / this._map.getZoom();
+
+ // width = Math.min(Math.max(width, 30), 64);
+ // height = Math.min(Math.max(height, 30), 64);
+
+
+ // let last_step = 0;
+
+
+ // Snap.animate(0, forth_path_length, function (step) {
+
+ // //show image when plane start to animate
+ // spaceship_img.attr({
+ // visibility: "visible"
+ // });
+
+ // spaceship_img.attr({width: width, height: height});
+
+ // last_step = step;
+
+ // let moveToPoint = Snap.path.getPointAtLength(flight_path, step);
+
+ // let x = moveToPoint.x - (width / 2);
+ // let y = moveToPoint.y - (height / 2);
+
+
+ // spaceship.transform('translate(' + x + ',' + y + ') rotate(' + (moveToPoint.alpha - 90) + ', ' + width / 2 + ', ' + height / 2 + ')');
+
+ // }, 2500, mina.easeout, function () {
+
+ // Snap.animate(forth_path_length, half_path_length, function (step) {
+
+ // last_step = step;
+ // let moveToPoint = Snap.path.getPointAtLength(flight_path, step);
+
+ // let x = moveToPoint.x - width / 2;
+ // let y = moveToPoint.y - height / 2;
+ // spaceship.transform('translate(' + x + ',' + y + ') rotate(' + (moveToPoint.alpha - 90) + ', ' + width / 2 + ', ' + height / 2 + ')');
+ // }, 7000, mina.easein, function () {
+ // //done
+
+ // });
+
+ // });
+
+
+ // },
+ getPath: function () {
+ return this._coords;
+ },
+ setPath: function (path) {
+ this._setPath(path);
+ return this.redraw();
+ },
+ getBounds: function () {
+ return this._bounds;
+ },
+ getMidPoint: function (from, to, deep, round_side = 'LEFT_ROUND') {
+
+ let offset = 3.14;
+
+ if (round_side === 'RIGHT_ROUND')
+ offset = offset * -1;
+
+ let latlngs = [];
+
+ let latlng1 = from,
+ latlng2 = to;
+
+ let offsetX = latlng2.lng - latlng1.lng,
+ offsetY = latlng2.lat - latlng1.lat;
+
+ let r = Math.sqrt(Math.pow(offsetX, 2) + Math.pow(offsetY, 2)),
+ theta = Math.atan2(offsetY, offsetX);
+
+ let thetaOffset = (offset / (deep ? deep : 4));
+
+ let r2 = (r / 2) / (Math.cos(thetaOffset)),
+ theta2 = theta + thetaOffset;
+
+ let midpointX = (r2 * Math.cos(theta2)) + latlng1.lng,
+ midpointY = (r2 * Math.sin(theta2)) + latlng1.lat;
+
+ let midpointLatLng = [midpointY, midpointX];
+
+ latlngs.push(latlng1, midpointLatLng, latlng2);
+
+ return midpointLatLng;
+ },
+ _setPath: function (path) {
+ this._coords = path;
+ this._bounds = this._computeBounds();
+ },
+ _computeBounds: function () {
+
+ let bound = new L.LatLngBounds();
+
+ bound.extend(this._coords.from);
+ bound.extend(this._coords.to);//for single destination
+ bound.extend(this._coords.mid);
+
+ return bound;
+ },
+ getCenter: function () {
+ return this._bounds.getCenter();
+ },
+ _update: function () {
+ if (!this._map) {
+ return;
+ }
+ this._updatePath();
+ },
+ _updatePath: function () {
+ //animated plane
+ let path = this._renderer._updatecurve(this);
+ // this.setAnimatePlane(path);
+ },
+ _project: function () {
+
+ this._points = [];
+
+ this._points.push('M');
+
+ let curPoint = this._map.latLngToLayerPoint(this._coords.from);
+ this._points.push(curPoint);
+
+ if (this._coords.mid) {
+ this._points.push('Q');
+ curPoint = this._map.latLngToLayerPoint(this._coords.mid);
+ this._points.push(curPoint);
+ }
+ curPoint = this._map.latLngToLayerPoint(this._coords.to);
+ this._points.push(curPoint);
+
+
+ },
+
+
+});
+
+L.bezier = function (config, options) {
+ let paths = [];
+ for (let i = 0; config.path.length > i; i++) {
+ let last_destination = false;
+ for (let c = 0; config.path[i].length > c; c++) {
+
+ let current_destination = config.path[i][c];
+ if (last_destination) {
+ let path_pair = {from: last_destination, to: current_destination};
+ paths.push(new Bezier(path_pair, config.icon, options));
+ }
+
+ last_destination = config.path[i][c];
+ }
+ }
+ return L.layerGroup(paths);
+
+};
+
+function noop() {}
+
+export {
+ Bezier,
+ noop,
+} \ No newline at end of file
diff --git a/client/nameSearch/index.js b/client/nameSearch/index.js
new file mode 100644
index 00000000..8c6475e4
--- /dev/null
+++ b/client/nameSearch/index.js
@@ -0,0 +1,5 @@
+import Container from './nameSearch.container'
+
+export {
+ Container,
+}
diff --git a/client/nameSearch/nameSearch.actions.js b/client/nameSearch/nameSearch.actions.js
new file mode 100644
index 00000000..290ee38d
--- /dev/null
+++ b/client/nameSearch/nameSearch.actions.js
@@ -0,0 +1,52 @@
+// import fetchJsonp from 'fetch-jsonp'
+import * as types from '../types'
+// import { hashPath } from '../util'
+import { post } from '../util'
+// import querystring from 'query-string'
+
+// urls
+
+const url = {
+ search: (dataset, q) => process.env.API_HOST + '/api/dataset/' + dataset + '/name?q=' + encodeURIComponent(q),
+}
+export const publicUrl = {
+}
+
+// standard loading events
+
+const loading = (tag, offset) => ({
+ type: types.nameSearch.loading,
+ tag,
+ offset
+})
+const loaded = (tag, data, offset = 0) => ({
+ type: types.nameSearch.loaded,
+ tag,
+ data,
+ offset
+})
+const error = (tag, err) => ({
+ type: types.nameSearch.error,
+ tag,
+ err
+})
+
+// search UI functions
+
+export const updateOptions = opt => dispatch => {
+ dispatch({ type: types.nameSearch.update_options, opt })
+}
+
+// API functions
+
+export const search = (payload, q) => dispatch => {
+ const tag = 'result'
+ const fd = new FormData()
+ fd.append('q', q)
+ dispatch(loading(tag))
+ post(url.search(payload.dataset, q), fd)
+ .then(data => {
+ dispatch(loaded(tag, data))
+ })
+ .catch(err => dispatch(error(tag, err)))
+}
diff --git a/client/nameSearch/nameSearch.container.js b/client/nameSearch/nameSearch.container.js
new file mode 100644
index 00000000..b0de0c3a
--- /dev/null
+++ b/client/nameSearch/nameSearch.container.js
@@ -0,0 +1,24 @@
+import React, { Component } from 'react'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import * as actions from './nameSearch.actions'
+
+import NameSearchQuery from './nameSearch.query'
+import NameSearchResult from './nameSearch.result'
+
+class NameSearchContainer extends Component {
+ render() {
+ const { payload } = this.props
+ // console.log(payload)
+ return (
+ <div className='searchContainer'>
+ <NameSearchQuery payload={payload} />
+ <NameSearchResult payload={payload} />
+ </div>
+ )
+ }
+}
+
+
+export default NameSearchContainer
diff --git a/client/nameSearch/nameSearch.query.js b/client/nameSearch/nameSearch.query.js
new file mode 100644
index 00000000..b82e324b
--- /dev/null
+++ b/client/nameSearch/nameSearch.query.js
@@ -0,0 +1,48 @@
+import React, { Component } from 'react'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import * as actions from './nameSearch.actions'
+
+class NameSearchQuery extends Component {
+ state = {
+ value: null
+ }
+
+ handleInput(value) {
+ this.setState({ q: value })
+ if (value.length > 2) {
+ this.props.actions.search(this.props.payload, value)
+ }
+ }
+
+ render() {
+ return (
+ <div className='query'>
+ <h2>Find Your Name</h2>
+ <h3>Searching {13456} identities</h3>
+ <p>
+ {'Enter your name to see if you were included in this dataset..'}
+ </p>
+ <input
+ type="text"
+ class="q"
+ placeholder="Enter your name"
+ value={this.state.q}
+ onInput={e => this.handleInput(e.target.value)}
+ />
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ result: state.nameSearch.result,
+ options: state.nameSearch.options,
+})
+
+const mapDispatchToProps = dispatch => ({
+ actions: bindActionCreators({ ...actions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(NameSearchQuery)
diff --git a/client/nameSearch/nameSearch.reducer.js b/client/nameSearch/nameSearch.reducer.js
new file mode 100644
index 00000000..101c93ea
--- /dev/null
+++ b/client/nameSearch/nameSearch.reducer.js
@@ -0,0 +1,32 @@
+import * as types from '../types'
+
+const initialState = () => ({
+ query: {},
+ result: {},
+ loading: false,
+})
+
+export default function nameSearchReducer(state = initialState(), action) {
+ switch (action.type) {
+ case types.nameSearch.loading:
+ return {
+ ...state,
+ [action.tag]: { loading: true },
+ }
+
+ case types.nameSearch.loaded:
+ return {
+ ...state,
+ [action.tag]: action.data,
+ }
+
+ case types.nameSearch.error:
+ return {
+ ...state,
+ [action.tag]: { error: action.err },
+ }
+
+ default:
+ return state
+ }
+}
diff --git a/client/nameSearch/nameSearch.result.js b/client/nameSearch/nameSearch.result.js
new file mode 100644
index 00000000..9e20228c
--- /dev/null
+++ b/client/nameSearch/nameSearch.result.js
@@ -0,0 +1,88 @@
+import React, { Component } from 'react'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import { courtesyS } from '../util'
+
+import * as actions from './nameSearch.actions'
+import { Loader } from '../common'
+
+const errors = {
+ nomatch: (
+ <div>
+ <h3>Name not found</h3>
+ {"No names matched your query."}
+ </div>
+ ),
+ error: (
+ <div>
+ <h3>{"No matches found"}</h3>
+ </div>
+ ),
+}
+
+class NameSearchResult extends Component {
+ render() {
+ const { dataset } = this.props.payload
+ const { query, results, loading, error } = this.props.result
+ console.log(this.props.result)
+ if (loading) {
+ return (
+ <div className='result'>
+ <div>
+ <Loader />
+ </div>
+ </div>
+ )
+ }
+ if (error) {
+ console.log(error)
+ let errorMessage = errors[error] || errors.error
+ return (
+ <div className='result'>{errorMessage}</div>
+ )
+ }
+ if (!results) {
+ return <div className='result'></div>
+ }
+ if (!results.length) {
+ return (
+ <div className='result'>{errors.nomatch}</div>
+ )
+ }
+ const els = results.map((result, i) => {
+ const { uuid } = result.uuid
+ const { fullname, gender, description, images } = result.identity
+ return (
+ <div key={i}>
+ <img src={'https://megapixels.nyc3.digitaloceanspaces.com/v1/media/' + dataset + '/' + uuid + '.jpg'} />
+ {fullname} {'('}{gender}{')'}<br/>
+ {description}<br/>
+ {courtesyS(images, 'image')}{' in dataset'}<br />
+ </div>
+ )
+ })
+
+ return (
+ <div className='result'>
+ <div className="timing">
+ {'Search took '}{Math.round(query.timing * 1000) + ' ms'}
+ </div>
+ <div className='results'>
+ {els}
+ </div>
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ query: state.nameSearch.query,
+ result: state.nameSearch.result,
+ options: state.nameSearch.options,
+})
+
+const mapDispatchToProps = dispatch => ({
+ actions: bindActionCreators({ ...actions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(NameSearchResult)
diff --git a/client/session.js b/client/session.js
new file mode 100644
index 00000000..0fae31d2
--- /dev/null
+++ b/client/session.js
@@ -0,0 +1,5 @@
+// import Storage from 'store2'
+
+// const session = Storage.namespace('vcat.search')
+
+// export default session
diff --git a/client/store.js b/client/store.js
new file mode 100644
index 00000000..13612f2d
--- /dev/null
+++ b/client/store.js
@@ -0,0 +1,30 @@
+import { applyMiddleware, compose, combineReducers, createStore } from 'redux'
+import thunk from 'redux-thunk'
+
+import faceSearchReducer from './faceSearch/faceSearch.reducer'
+import nameSearchReducer from './nameSearch/nameSearch.reducer'
+
+const rootReducer = combineReducers({
+ faceSearch: faceSearchReducer,
+ nameSearch: nameSearchReducer,
+})
+
+function configureStore(initialState = {}) {
+ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
+
+ const store = createStore(
+ rootReducer,
+ initialState,
+ composeEnhancers(
+ applyMiddleware(
+ thunk,
+ ),
+ ),
+ )
+
+ return store
+}
+
+const store = configureStore({})
+
+export { store }
diff --git a/client/tables.js b/client/tables.js
new file mode 100644
index 00000000..b4c13887
--- /dev/null
+++ b/client/tables.js
@@ -0,0 +1,81 @@
+import Tabulator from 'tabulator-tables'
+import csv from 'parse-csv'
+
+const datasetColumns = [
+ { title: 'Title', field: 'title', sorter: 'string' },
+ { title: 'Images', field: 'images', sorter: 'number' },
+ { title: 'People', field: 'people', sorter: 'number' },
+ { title: 'Year', field: 'year', sorter: 'number' },
+ { title: 'Citations', field: 'citations', sorter: 'number' },
+ { title: 'Influenced', field: 'influenced', sorter: 'number' },
+ // { title: 'Origin', field: 'origin', sorter: 'string' },
+]
+const citationsColumns = [
+ { title: 'Title', field: 'title', sorter: 'string' },
+ { title: 'Institution', field: 'institution', sorter: 'string' },
+]
+
+function getColumns(payload) {
+ let { cmd, url, fields } = payload
+ if (cmd === 'citations') {
+ return citationsColumns
+ }
+ if (url && url.match('datasets.csv')) {
+ return datasetColumns
+ }
+ return ((fields && fields.length) ? fields[0] : '').split(', ').map(field => {
+ switch (field) {
+ default:
+ return { title: field, field: field.toLowerCase(), sorter: 'string' }
+ }
+ })
+}
+
+function getCitations(dataset) {
+ console.log(dataset.citations)
+ return dataset.citations.map(c => ({
+ title: c[0],
+ institution: c[2],
+ }))
+}
+
+export default function append(el, payload) {
+ const columns = getColumns(payload)
+ console.log(columns)
+ const table = new Tabulator(el, {
+ height: '311px',
+ layout: 'fitColumns',
+ placeholder: 'No Data Set',
+ columns,
+ })
+ // let path = payload.opt
+ // console.log(path, columns)
+
+ console.log(payload.cmd, payload.url, payload.dataset)
+ if (payload.cmd === 'citations') {
+ let { data } = payload
+ const citations = getCitations(data)
+ console.log(citations)
+ table.setData(citations)
+ el.classList.add('loaded')
+ } else {
+ fetch(payload.url, { mode: 'cors' })
+ .then(r => r.text())
+ .then(text => {
+ try {
+ const data = csv.toJSON(text, { headers: { included: true } })
+ // console.log(data)
+ table.setData(data)
+ el.classList.add('loaded')
+ } catch (e) {
+ console.error("error making json:", payload.url)
+ console.error(e)
+ // console.log(text)
+ }
+ })
+ }
+
+ if (fields.length > 1 && fields[1].indexOf('filter')) {
+ const filter = fields[1].split(' ')
+ }
+}
diff --git a/client/types.js b/client/types.js
new file mode 100644
index 00000000..fb1fbe30
--- /dev/null
+++ b/client/types.js
@@ -0,0 +1,17 @@
+export const asType = (type, name) => [type, name].join('_').toUpperCase()
+export const tagAsType = (type, names) => (
+ names.reduce((tags, name) => {
+ tags[name] = asType(type, name)
+ return tags
+ }, {})
+)
+
+export const faceSearch = tagAsType('faceSearch', [
+ 'loading', 'loaded', 'error', 'update_options',
+])
+
+export const nameSearch = tagAsType('nameSearch', [
+ 'loading', 'loaded', 'error', 'update_options',
+])
+
+export const init = '@@INIT'
diff --git a/client/util.js b/client/util.js
new file mode 100644
index 00000000..f181ad0f
--- /dev/null
+++ b/client/util.js
@@ -0,0 +1,107 @@
+/* Mobile check */
+
+export const isiPhone = !!((navigator.userAgent.match(/iPhone/i)) || (navigator.userAgent.match(/iPod/i)))
+export const isiPad = !!(navigator.userAgent.match(/iPad/i))
+export const isAndroid = !!(navigator.userAgent.match(/Android/i))
+export const isMobile = isiPhone || isiPad || isAndroid
+export const isDesktop = !isMobile
+
+export const toArray = a => Array.prototype.slice.apply(a)
+export const choice = a => a[Math.floor(Math.random() * a.length)]
+
+const htmlClassList = document.body.parentNode.classList
+htmlClassList.add(isDesktop ? 'desktop' : 'mobile')
+
+/* Default image dimensions */
+
+export const widths = {
+ th: 160,
+ sm: 320,
+ md: 640,
+ lg: 1280,
+}
+
+/* Formatting functions */
+
+const acronyms = 'id url cc sa fp md5 sha256'.split(' ').map(s => '_' + s)
+const acronymsUpperCase = acronyms.map(s => s.toUpperCase())
+
+export const formatName = s => {
+ acronyms.forEach((acronym, i) => s = s.replace(acronym, acronymsUpperCase[i]))
+ return s.replace(/_/g, ' ')
+}
+
+// Use to pad frame numbers with zeroes
+export const pad = (n, m) => {
+ let s = String(n || 0)
+ while (s.length < m) {
+ s = '0' + s
+ }
+ return s
+}
+
+export const courtesyS = (n, s) => n + ' ' + (n === 1 ? s : s + 's')
+
+export const padSeconds = n => n < 10 ? '0' + n : n
+
+export const timestamp = (n = 0, fps = 25) => {
+ n /= fps
+ let s = padSeconds(Math.round(n) % 60)
+ n = Math.floor(n / 60)
+ if (n > 60) {
+ return Math.floor(n / 60) + ':' + padSeconds(n % 60) + ':' + s
+ }
+ return (n % 60) + ':' + s
+}
+
+export const percent = n => (n * 100).toFixed(1) + '%'
+export const px = (n, w) => Math.round(n * w) + 'px'
+export const clamp = (n, a, b) => n < a ? a : n < b ? n : b
+
+/* URLs */
+
+export const preloadImage = opt => {
+ let { verified, hash, frame, url } = opt
+ if (hash && frame) {
+ url = imageUrl(verified, hash, frame, 'md')
+ }
+ const image = new Image()
+ let loaded = false
+ image.onload = () => {
+ if (loaded) return
+ loaded = true
+ image.onload = null
+ }
+ // console.log(img.src)
+ image.crossOrigin = 'anonymous'
+ image.src = url
+ if (image.complete) {
+ image.onload()
+ }
+}
+
+/* AJAX */
+
+export const post = (uri, data) => {
+ let headers
+ if (data instanceof FormData) {
+ headers = {
+ Accept: 'application/json, application/xml, text/play, text/html, *.*',
+ }
+ } else {
+ headers = {
+ Accept: 'application/json, application/xml, text/play, text/html, *.*',
+ 'Content-Type': 'application/json; charset=utf-8',
+ }
+ data = JSON.stringify(data)
+ }
+ let opt = {
+ method: 'POST',
+ body: data,
+ headers,
+ credentials: 'include',
+ }
+ // console.log(headers)
+ // headers['X-CSRFToken'] = csrftoken
+ return fetch(uri, opt).then(res => res.json())
+}