diff options
Diffstat (limited to 'animism-align/frontend/app/views/align/components')
15 files changed, 821 insertions, 0 deletions
diff --git a/animism-align/frontend/app/views/align/components/annotations/annotation.form.js b/animism-align/frontend/app/views/align/components/annotations/annotation.form.js new file mode 100644 index 0000000..7d66272 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotation.form.js @@ -0,0 +1,182 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { ZOOM_STEPS } from 'app/constants' +import { clamp, timestamp, capitalize } from 'app/utils' +import { timeToPosition } from 'app/views/align/align.util' +import { Select } from 'app/common' + +import { + AnnotationFormVideo, + AnnotationFormImage, +} from './annotationForms' + +const ANNOTATION_TYPES = [ + 'sentence', 'header', 'paragraph_end', 'video', 'image', 'image_carousel', +].map(name => ({ name, label: capitalize(name.replace('_', ' ')) })) + +class AnnotationForm extends Component { + constructor(props){ + super(props) + this.handleChange = this.handleChange.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleSettingsSelect = this.handleSettingsSelect.bind(this) + this.handleKeyDown = this.handleKeyDown.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + this.handleDestroy = this.handleDestroy.bind(this) + this.textareaRef = React.createRef() + } + componentDidMount() { + if (this.textareaRef && this.textareaRef.current) { + this.textareaRef.current.focus() + } + } + handleKeyDown(e) { + if (e.keyCode === 27) { // escape + actions.align.hideAnnotationForm() + return + } + // console.log(e.keyCode) + if (!e.metaKey && !e.ctrlKey) return + let { start_ts } = this.props.annotation + switch (e.keyCode) { + case 38: // up + e.preventDefault() + start_ts -= 0.1 + actions.align.updateAnnotationForm('start_ts', Math.max(0, start_ts)) + actions.audio.seek(start_ts) + actions.align.setCursor(start_ts) + break + case 40: // down + e.preventDefault() + start_ts += 0.1 + actions.align.updateAnnotationForm('start_ts', Math.max(0, start_ts)) + actions.audio.seek(start_ts) + actions.align.setCursor(start_ts) + break + case 83: // ctrl-S + e.preventDefault() + this.handleSubmit() + default: + break + } + } + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + handleSelect(name, value) { + actions.align.updateAnnotationForm(name, value) + } + handleSettingsSelect(name, value) { + if (name.indexOf('_id') !== -1) value = parseInt(value) || 0 + actions.align.updateAnnotationSettings(name, value) + } + handleSubmit() { + const { annotation } = this.props + if (annotation.type === 'paragraph_end') { + annotation.text = '' + } + if (annotation.id === 'new') { + delete annotation.id + actions.annotation.create(annotation) + .then(response => { + console.log(response) + actions.align.hideAnnotationForm() + }) + } else { + actions.annotation.update(annotation) + .then(response => { + console.log(response) + actions.align.hideAnnotationForm() + }) + } + } + handleDestroy() { + const { annotation } = this.props + if (annotation.id === 'new') { + actions.align.hideAnnotationForm() + } else { + actions.annotation.destroy(annotation) + .then(response => { + console.log(response) + actions.align.hideAnnotationForm() + }) + } + } + render() { + const { timeline, annotation, media } = this.props + if (!annotation.start_ts) return <div></div> + return ( + <div + className='annotationForm' + style={{ + top: timeToPosition(annotation.start_ts, timeline), + }} + > + {this.renderButtons()} + {annotation.type === 'sentence' && this.renderTextarea()} + {annotation.type === 'header' && this.renderTextarea()} + {annotation.type === 'video' && + <AnnotationFormVideo annotation={annotation} media={media} handleSettingsSelect={this.handleSettingsSelect} /> + } + {annotation.type === 'image' && + <AnnotationFormImage annotation={annotation} media={media} handleSettingsSelect={this.handleSettingsSelect} /> + } + {annotation.type === 'image_carousel' && + <AnnotationFormImageCarousel annotation={annotation} media={media} handleSettingsSelect={this.handleSettingsSelect} /> + } + </div> + ) + } + renderButtons() { + const { annotation } = this.props + return ( + <div className='row buttons'> + <div> + <Select + name='type' + selected={annotation.type} + options={ANNOTATION_TYPES} + defaultOption='text' + onChange={this.handleSelect} + /> + <div className='ts'>{timestamp(annotation.start_ts, 1, true)}</div> + </div> + <div> + {annotation.id !== 'new' && <button onClick={this.handleDestroy}>Delete</button>} + <button onClick={this.handleSubmit}>Save</button> + </div> + </div> + ) + } + renderTextarea() { + const { annotation } = this.props + return ( + <div> + <textarea + name='text' + value={annotation.text} + onKeyDown={this.handleKeyDown} + onChange={this.handleChange} + ref={this.textareaRef} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + annotation: state.align.annotation, + timeline: state.align.timeline, + media: state.media.index, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AnnotationForm) diff --git a/animism-align/frontend/app/views/align/components/annotations/annotation.index.js b/animism-align/frontend/app/views/align/components/annotations/annotation.index.js new file mode 100644 index 0000000..aa31268 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotation.index.js @@ -0,0 +1,126 @@ +import React, { Component } from 'react' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { ZOOM_STEPS, INNER_HEIGHT } from 'app/constants' +import { clamp } from 'app/utils' +import { positionToTime, timeToPosition } from 'app/views/align/align.util' + +import { AnnotationElementLookup } from './annotationTypes' + +class AnnotationIndex extends Component { + state = { + items: [], + } + constructor(props){ + super(props) + this.handleClick = this.handleClick.bind(this) + } + componentDidUpdate(prevProps) { + if (this.props.index.loading) return + if (prevProps.timeline !== this.props.timeline || prevProps.index !== this.props.index) { + this.update() + } + } + update() { + let { timeline, index } = this.props + let { start_ts, zoom, duration } = this.props.timeline + const { order, lookup } = index + + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + let widthTimeDuration = INNER_HEIGHT * secondsPerPixel // secs per pixel + + let timeMin = start_ts - 50.0 + let timeMax = Math.min(start_ts + widthTimeDuration, duration) + + const items = order.filter(id => { + const { start_ts: ts } = lookup[id] + return (timeMin < ts && ts < timeMax) + }).map(id => lookup[id]).reverse() + this.setState({ items }) + } + handleClick(e, annotation) { + e.stopPropagation() + if (e.shiftKey) { + e.preventDefault() + this.handleParagraphSelection(annotation, e.metaKey) + } + actions.audio.seek(annotation.start_ts) + actions.align.setSelectedAnnotation(annotation) + } + handleParagraphSelection(annotation, shouldClear) { + const { selected_paragraph_id } = this.props.timeline + if (!selected_paragraph_id || selected_paragraph_id === -1 || shouldClear) { + if (annotation.paragraph_id && !shouldClear) { + actions.align.setSelectedParagraph(annotation.paragraph_id) + } else { + actions.paragraph.create({ + type: 'paragraph', + start_ts: annotation.start_ts, + }).then(data => { + actions.align.setSelectedParagraph(data.res.id) + annotation.paragraph_id = data.res.id + actions.annotation.update(annotation) + }) + } + } else if (selected_paragraph_id !== annotation.paragraph_id) { + annotation.paragraph_id = selected_paragraph_id + actions.annotation.update(annotation) + } + } + handleDoubleClick(e, annotation) { + e.stopPropagation() + actions.align.showEditAnnotationForm(annotation) + } + render() { + const { timeline, media, annotationInForm, selectedAnnotation } = this.props + const { start_ts, zoom, selected_annotation_id } = timeline + const { items } = this.state + const className = (zoom < 2) + ? 'annotationIndex' + : (zoom < 3) + ? 'annotationIndex condensed' + : 'annotationIndex collapsed' + return ( + <div className={className}> + {items.map(annotation => { + if (annotationInForm && annotation.id === annotationInForm.id) { + return null + } + if (annotation.id === selected_annotation_id) { + annotation = selectedAnnotation + } + const { id, type, start_ts } = annotation + const AnnotationElement = AnnotationElementLookup[type] + const y = timeToPosition(start_ts, timeline) + return ( + <AnnotationElement + key={id} + y={y} + selected={annotation.id === selected_annotation_id} + annotation={annotation} + media={media} + onClick={this.handleClick} + onDoubleClick={this.handleDoubleClick} + /> + ) + })} + </div> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + annotationInForm: state.align.annotation, + selectedAnnotation: state.align.selectedAnnotation, + index: state.annotation.index, + media: state.media.index.lookup, +}) + +const mapDispatchToProps = dispatch => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AnnotationIndex) diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js new file mode 100644 index 0000000..8457b68 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.image.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' + +import { Select } from 'app/common' + +export const AnnotationFormImage = ({ annotation, media, handleSettingsSelect }) => { + if (!media.lookup) return <div /> + const { lookup, order } = media + const image_list_items = order.filter(id => lookup[id].type === 'image').map(id => { + const image = lookup[id] + return { + name: image.id, + label: image.author + ' - ' + image.title + } + }) + return ( + <div> + <Select + name='media_id' + className="media_id" + selected={annotation.settings.media_id} + options={image_list_items} + defaultOption='Choose an image' + onChange={handleSettingsSelect} + /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js new file mode 100644 index 0000000..1fb552b --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationForms/annotationForm.video.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react' + +import { Select } from 'app/common' + +export const AnnotationFormVideo = ({ annotation, media, handleSettingsSelect }) => { + if (!media.lookup) return <div /> + const { lookup, order } = media + const video_list_items = order.filter(id => lookup[id].type === 'video').map(id => { + const video = lookup[id] + return { + name: video.id, + label: video.author + ' - ' + video.title + } + }) + return ( + <div> + <Select + name='media_id' + className="media_id" + selected={annotation.settings.media_id} + options={video_list_items} + defaultOption='Choose a video' + onChange={handleSettingsSelect} + /> + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js b/animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js new file mode 100644 index 0000000..1411efc --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationForms/index.js @@ -0,0 +1,12 @@ +import { + AnnotationFormVideo, +} from './annotationForm.video' + +import { + AnnotationFormImage, +} from './annotationForm.image' + +export { + AnnotationFormImage, + AnnotationFormVideo, +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js new file mode 100644 index 0000000..ec4d25e --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.image.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' + +import { thumbnailURL } from 'app/views/align/align.util' + +import { checkAnnotationMediaNotReady, AnnotationMediaLoading } from './annotationTypes.util' + +export const AnnotationImage = ({ y, annotation, media, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation media image selected' : 'annotation media image' + if (checkAnnotationMediaNotReady(annotation, media)) { + return <AnnotationMediaLoading y={y} className={className} onClick={onClick} onDoubleClick={onDoubleClick} /> + } + const data = media[annotation.settings.media_id] + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + <div className='img'> + <img src={thumbnailURL(data)} alt={data.title} /> + </div> + <div className='meta center'> + <div> + <i>{data.title}</i><br /> + {data.author}<br /> + {data.date} + </div> + </div> + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js new file mode 100644 index 0000000..be4674f --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.text.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react' + +export const AnnotationSentence = ({ y, annotation, selected, onClick, onDoubleClick }) => { + const { start_ts, text, paragraph_id } = annotation + let className = !paragraph_id + ? 'annotation sentence' + : (paragraph_id % 2) + ? 'annotation sentence odd' + : 'annotation sentence even' + if (selected) className += ' selected' + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + dangerouslySetInnerHTML={{ __html: text }} + /> + ) +} + +export const AnnotationHeader = ({ y, annotation, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation header selected' : 'annotation header' + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + {text} + </div> + ) +} + +export const AnnotationParagraphEnd = ({ y, annotation, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation paragraph_end selected' : 'annotation paragraph_end' + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + </div> + ) +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js new file mode 100644 index 0000000..17abebd --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.util.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react' + +export const checkAnnotationMediaNotReady = (annotation, media) => { + return (!media) || (!(annotation.settings.media_id in media)) +} + +export const AnnotationMediaLoading = ({ y, className, onClick, onDoubleClick }) => { + if (!media) { + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + >LOADING...</div> + ) + } + if (!(annotation.settings.media_id in media)) { + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + >MEDIA NOT FOUND</div> + ) + } +} diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js new file mode 100644 index 0000000..f51ac71 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/annotationTypes.video.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' + +import { thumbnailURL } from 'app/views/align/align.util' +import { checkAnnotationMediaNotReady, AnnotationMediaLoading } from './annotationTypes.util' + +export const AnnotationVideo = ({ y, annotation, media, selected, onClick, onDoubleClick }) => { + const { start_ts, text } = annotation + const className = selected ? 'annotation media video selected' : 'annotation media video' + if (checkAnnotationMediaNotReady(annotation, media)) { + return <AnnotationMediaLoading y={y} className={className} onClick={onClick} onDoubleClick={onDoubleClick} /> + } + const data = media[annotation.settings.media_id] + return ( + <div + className={className} + style={{ top: y }} + onClick={e => onClick(e, annotation)} + onDoubleClick={e => onDoubleClick(e, annotation)} + > + <div className='img'> + <img src={thumbnailURL(data)} alt={data.title} /> + </div> + <div className='meta center'> + <div> + <i>{data.title}</i><br /> + {data.author}<br /> + {data.date} + </div> + </div> + </div> + ) +} + diff --git a/animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js new file mode 100644 index 0000000..560063b --- /dev/null +++ b/animism-align/frontend/app/views/align/components/annotations/annotationTypes/index.js @@ -0,0 +1,22 @@ +import React from 'react' + +import { + AnnotationSentence, AnnotationHeader, + AnnotationParagraphEnd, +} from './annotationTypes.text' + +import { + AnnotationVideo, +} from './annotationTypes.video' + +import { + AnnotationImage, +} from './annotationTypes.image' + +export const AnnotationElementLookup = { + sentence: React.memo(AnnotationSentence), + header: React.memo(AnnotationHeader), + paragraph_end: React.memo(AnnotationParagraphEnd), + video: React.memo(AnnotationVideo), + image: React.memo(AnnotationImage), +} diff --git a/animism-align/frontend/app/views/align/components/player/playButton.component.js b/animism-align/frontend/app/views/align/components/player/playButton.component.js new file mode 100644 index 0000000..c6a8487 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/player/playButton.component.js @@ -0,0 +1,31 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' +// import * as alignActions from '../align.actions' + +import { ZOOM_STEPS } from 'app/constants' +import { clamp } from 'app/utils' + +const PlayButton = ({ audio }) => { + return ( + <div + className={audio.playing ? 'playButton playing' : 'playButton paused'} + onClick={() => { + audio.playing ? actions.audio.pause() : actions.audio.play() + }} + /> + ) +} + +const mapStateToProps = state => ({ + audio: state.audio, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(PlayButton) diff --git a/animism-align/frontend/app/views/align/components/timeline/cursor.component.js b/animism-align/frontend/app/views/align/components/timeline/cursor.component.js new file mode 100644 index 0000000..4a94100 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/cursor.component.js @@ -0,0 +1,26 @@ +import React, { Component } from 'react' + +import { ZOOM_STEPS } from 'app/constants' +import { timestamp } from 'app/utils' + +const Cursor = ({ timeline, annotation }) => { + const { start_ts, zoom, cursor_ts, duration } = timeline + const ts = annotation.start_ts || cursor_ts + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const y = (ts - start_ts) / secondsPerPixel + return ( + <div + className='cursor' + style={{ + top: y, + }} + > + <div className='line' /> + <div className='tickLabel'> + {timestamp(ts, 1)} + </div> + </div> + ) +} + +export default Cursor
\ No newline at end of file diff --git a/animism-align/frontend/app/views/align/components/timeline/playCursor.component.js b/animism-align/frontend/app/views/align/components/timeline/playCursor.component.js new file mode 100644 index 0000000..854c43b --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/playCursor.component.js @@ -0,0 +1,36 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import { ZOOM_STEPS } from 'app/constants' +import { timestamp } from 'app/utils' + +const PlayCursor = ({ timeline, audio }) => { + const { start_ts, zoom, duration } = timeline + const { play_ts } = audio + const secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 + const y = (play_ts - start_ts) / secondsPerPixel + // console.log(play_ts, y) + return ( + <div + className='cursor playCursor' + style={{ + top: y, + }} + > + <div className='line' /> + </div> + ) +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + audio: state.audio, +}) + +const mapDispatchToProps = dispatch => ({ + // alignActions: bindActionCreators({ ...alignActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(PlayCursor) diff --git a/animism-align/frontend/app/views/align/components/timeline/ticks.component.js b/animism-align/frontend/app/views/align/components/timeline/ticks.component.js new file mode 100644 index 0000000..4530863 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/ticks.component.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react' + +import { ZOOM_STEPS, ZOOM_LABEL_STEPS, ZOOM_TICK_STEPS, INNER_HEIGHT } from 'app/constants' +import { timestamp } from 'app/utils' + +export default class Ticks extends Component { + render() { + let { start_ts, zoom, duration } = this.props.timeline + + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + + let widthTimeDuration = INNER_HEIGHT * secondsPerPixel // secs per pixel + + let timeMin = start_ts + let timeMax = Math.min(start_ts + widthTimeDuration, duration) + let timeWidth = timeMax - timeMin + + let pixelMin = timeMin / secondsPerPixel + + let secondsPerLabel = ZOOM_LABEL_STEPS[zoom] // secs + let pixelsPerLabel = secondsPerLabel / secondsPerPixel + let secondsPerTick = ZOOM_TICK_STEPS[zoom] + let pixelsPerTick = secondsPerTick / secondsPerPixel + + let startOffset = pixelsPerLabel - (pixelMin % pixelsPerLabel) + let startTiming = (pixelMin + startOffset) * secondsPerPixel + + let labelCount = Math.ceil(INNER_HEIGHT / pixelsPerLabel) + 1 + let offset, timing, tickLabels = [], ticks = [] + for (var i = -1; i < labelCount; i++) { + offset = i * pixelsPerLabel + startOffset + if (offset > INNER_HEIGHT) continue + timing = i * secondsPerLabel + startTiming + if (timing > duration) { + break + } + tickLabels.push( + <div className='tickLabel' key={"tickLabel_" + i} + style={{ + top: Math.floor(offset) + }}> + {timestamp(timing)} + </div> + ) + } + + let durationOffset = duration / secondsPerPixel - pixelMin + if (timing > duration) { + tickLabels.push( + <div className='tickLabel tickLabelTotal' key={"tickLabel_total"} + style={{ + top: durationOffset + }}> + {timestamp(duration, 1)} + </div> + ) + ticks.push( + <div className='tick' key={"tick_total"} + style={{ + top: Math.floor(durationOffset), + }} + /> + ) + } + let tickCount = Math.ceil(INNER_HEIGHT / pixelsPerTick) + 6 + for (var i = 0; i < tickCount; i += 1) { + offset = i * pixelsPerTick + startOffset - pixelsPerLabel + if (offset > durationOffset) { + break + } + ticks.push( + <div className='tick' key={"tick_" + i} + style={{ + top: Math.floor(offset), + }} + /> + ) + } + // console.log(ticks.length) + + return ( + <div className='ticks'> + {ticks} + {tickLabels} + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/align/components/timeline/waveform.component.js b/animism-align/frontend/app/views/align/components/timeline/waveform.component.js new file mode 100644 index 0000000..023b877 --- /dev/null +++ b/animism-align/frontend/app/views/align/components/timeline/waveform.component.js @@ -0,0 +1,101 @@ +import React, { Component } from 'react' +// import { Link } from 'react-router-dom' +// import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import actions from 'app/actions' + +import { + WAVEFORM_SIZE, INNER_HEIGHT, + ZOOM_STEPS, ZOOM_LABEL_STEPS, ZOOM_TICK_STEPS, +} from 'app/constants' + +class Waveform extends Component { + constructor(props){ + super(props) + this.canvasRef = React.createRef() + } + componentDidMount() { + this.resize() + this.draw() + } + componentDidUpdate() { + this.draw() + } + resize() { + const canvas = this.canvasRef.current + canvas.width = WAVEFORM_SIZE + canvas.height = INNER_HEIGHT + } + draw() { + const canvas = this.canvasRef.current + const ctx = canvas.getContext('2d') + const h = INNER_HEIGHT + this.clearCanvas(ctx, h) + this.drawCurve(ctx, h) + } + clearCanvas(ctx, h) { + const w = WAVEFORM_SIZE + ctx.clearRect(0, 0, w, h) + ctx.fillStyle = 'rgba(0,0,0,0.5)' + ctx.fillRect(0, 0, w, h) + ctx.fillStyle = 'rgba(64,128,192,0.5)' + } + drawCurve(ctx, h) { + let { peaks, timeline } = this.props + let { start_ts, zoom, duration } = timeline + + let secondsPerPixel = ZOOM_STEPS[zoom] * 0.1 // 0.1 sec / step + let stepsPerPixel = ZOOM_STEPS[zoom] // 0.1 sec / step + + let widthTimeDuration = h * secondsPerPixel // secs per pixel + + let timeMin = Math.round(start_ts / secondsPerPixel) * secondsPerPixel + let timeMax = Math.min(timeMin + widthTimeDuration, duration) + let timeWidth = timeMax - timeMin + + let stepMin = Math.floor(timeMin * 10) + let pixelWidth = Math.ceil(timeWidth / secondsPerPixel) + + let i = 0 + let step = stepMin + let waveformPeak = WAVEFORM_SIZE / 2 + let origin = (1 - peaks[step]) * waveformPeak + let y + let peak + // console.log(stepMin, pixelWidth * stepsPerPixel + stepMin) + ctx.beginPath() + ctx.moveTo(origin, 0) + for (i = 0; i < pixelWidth; i++) { + step = i * stepsPerPixel + stepMin + peak = peaks[step] + y = (1 - peak) * waveformPeak + ctx.lineTo(y, i) + } + for (i = pixelWidth - 1; i > 0; i--) { + step = i * stepsPerPixel + stepMin + peak = peaks[step] + y = (1 + peak) * waveformPeak + ctx.lineTo(y, i) + } + ctx.lineTo(origin, 0) + ctx.fillStyle = 'rgba(255,255,255,0.8)' + ctx.fill() + } + render() { + return ( + <canvas ref={this.canvasRef} onClick={this.props.onClick} /> + ) + } +} + +const mapStateToProps = state => ({ + timeline: state.align.timeline, + peaks: state.site.peaks, +}) + +const mapDispatchToProps = dispatch => ({ + // uploadActions: bindActionCreators({ ...uploadActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Waveform) |
