diff options
Diffstat (limited to 'client')
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 © <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()) +} |
