diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/SubjectViewer.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/SubjectViewer.js index 5128b8fbb4..31b8f7431e 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/SubjectViewer.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/SubjectViewer.js @@ -2,18 +2,17 @@ import asyncStates from '@zooniverse/async-states' import PropTypes from 'prop-types' import { useTranslation } from '@translations/i18n' import { lazy, Suspense } from 'react' -// import VolumetricViewer from '@zooniverse/subject-viewers/VolumetricViewer' import { withStores } from '@helpers' import getViewer from './helpers/getViewer' const VolumetricViewer = lazy(() => import('@zooniverse/subject-viewers/VolumetricViewer')) -const ProtoViewer = lazy(() => import('@zooniverse/subject-viewers/ProtoViewer')) function storeMapper(classifierStore) { const { subjects: { active: subject, loadingState: subjectQueueState }, - subjectViewer: { onSubjectReady, onError, loadingState: subjectReadyState } + subjectViewer: { onSubjectReady, onError, loadingState: subjectReadyState }, + projects: { active: project } } = classifierStore const drawingTasks = classifierStore?.workflowSteps.findTasksByType('drawing') @@ -22,6 +21,7 @@ function storeMapper(classifierStore) { return { enableInteractionLayer, + isVolumetricViewer: project?.isVolumetricViewer ?? false, onError, onSubjectReady, subject, @@ -32,6 +32,7 @@ function storeMapper(classifierStore) { function SubjectViewer({ enableInteractionLayer, + isVolumetricViewer, onError, onSubjectReady, subject, @@ -52,12 +53,9 @@ function SubjectViewer({ return null } case asyncStates.success: { - let Viewer - if (subject?.viewer === 'volumetric') { - Viewer = ProtoViewer - } else { - Viewer = getViewer(subject?.viewer) - } + const Viewer = (isVolumetricViewer) + ? VolumetricViewer + : getViewer(subject?.viewer) if (Viewer) { return ( diff --git a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/SubjectViewer.spec.js b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/SubjectViewer.spec.js index 7f0d271949..35ac7a0dac 100644 --- a/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/SubjectViewer.spec.js +++ b/packages/lib-classifier/src/components/Classifier/components/SubjectViewer/SubjectViewer.spec.js @@ -1,60 +1,67 @@ +import { render, screen } from '@testing-library/react' import asyncStates from '@zooniverse/async-states' -import { shallow } from 'enzyme' - -import { SubjectViewer } from './SubjectViewer' -import SingleImageViewer from './components/SingleImageViewer' -import JSONDataViewer from './components/JSONDataViewer' +import { Factory } from 'rosie' +import mockStore from '@test/mockStore' +import { Provider } from 'mobx-react' +import SubjectType from '@store/SubjectStore/SubjectType' +import { default as SubjectViewerWithStore, SubjectViewer } from './SubjectViewer' describe('Component > SubjectViewer', function () { it('should render without crashing', function () { - const wrapper = shallow() - expect(wrapper).to.be.ok() + render() + expect(screen).to.be.ok() }) it('should render nothing if the subject store is initialized', function () { - const wrapper = shallow() - expect(wrapper.type()).to.be.null() + const { container } = render() + expect(container.firstChild).to.be.null() }) - + it('should render a loading indicator if the subject store is loading', function () { - const wrapper = shallow() - expect(wrapper.text()).to.equal('SubjectViewer.loading') + render() + expect(screen.getByText('SubjectViewer.loading')).to.exist() }) it('should render nothing if the subject store errors', function () { - const wrapper = shallow() - expect(wrapper.type()).to.be.null() + const { container } = render() + expect(container.firstChild).to.be.null() }) - it('should render a subject viewer if the subject store successfully loads', function () { - const wrapper = shallow() - expect(wrapper.find(SingleImageViewer)).to.have.lengthOf(1) - }) + it('should render a subject viewer if the subject store successfully loads', async function () { + const store = mockStore({ + subject: SubjectType.create(Factory.build('subject', { id: '1234' })) + }) + + render( + + ) - it('should pass along the viewer configuration', function () { - const viewerConfiguration = { - zoomConfiguration: { - direction: 'both', - minZoom: 1, - maxZoom: 10, - zoomInValue: 1.2, - zoomOutValue: 0.8 - } - } + expect(screen.getByLabelText('Subject 1234')).to.exist() + }) - const wrapper = shallow() - expect(wrapper.find(JSONDataViewer).props().viewerConfiguration).to.deep.equal(viewerConfiguration) + it('should render the VolumetricViewer if isVolumetricViewer = true', async function () { + render() + expect(screen.getByText('Suspense boundary')).to.exist() + expect(await screen.findByTestId('subject-viewer-volumetric')).to.exist() }) describe('when there is an null viewer because of invalid subject media', function () { it('should render null', function () { - const wrapper = shallow( + const { container } = render( ) - expect(wrapper.html()).to.be.null() + expect(container.firstChild).to.be.null() }) }) }) diff --git a/packages/lib-classifier/src/store/Project/Project.js b/packages/lib-classifier/src/store/Project/Project.js index 7a009f03b3..6fc0a93e90 100644 --- a/packages/lib-classifier/src/store/Project/Project.js +++ b/packages/lib-classifier/src/store/Project/Project.js @@ -24,6 +24,10 @@ const Project = types get display_name() { return self.strings.get('display_name') }, + + get isVolumetricViewer() { + return self.experimental_tools.includes('volumetricViewer') + }, })) export default types.compose('ProjectResource', Resource, Project) diff --git a/packages/lib-subject-viewers/package.json b/packages/lib-subject-viewers/package.json index 73b3603344..40bae97ff9 100644 --- a/packages/lib-subject-viewers/package.json +++ b/packages/lib-subject-viewers/package.json @@ -40,6 +40,7 @@ "three": "^0.162.0" }, "peerDependencies": { + "@zooniverse/async-states": "~0.0.1", "@zooniverse/grommet-theme": "3.x.x", "grommet": "2.x.x", "grommet-icons": "4.x.x", diff --git a/packages/lib-subject-viewers/src/ProtoViewer/ProtoViewer.js b/packages/lib-subject-viewers/src/ProtoViewer/ProtoViewer.js deleted file mode 100644 index fafd6ac63d..0000000000 --- a/packages/lib-subject-viewers/src/ProtoViewer/ProtoViewer.js +++ /dev/null @@ -1,66 +0,0 @@ -import asyncStates from '@zooniverse/async-states' -import { useEffect } from 'react' - -const DEFAULT_HANDLER = () => {} - -const defaultSubject = { - id: '', - locations: [] -} - -const defaultTarget = { - clientHeight: 0, - clientWidth: 0, - naturalHeight: 0, - naturalWidth: 0 -} - -export default function ProtoViewer({ - /* - loadingState is defined in SubjectViewerStore.js. onReady and onError affect it - and then it's used below to choose rendering options - */ - loadingState = asyncStates.initialized, - /* - onError is defined in lib-classifier's SubjectViewerStore.js. When called, it sets - SubjectViewerStore's loadingState to error. - */ - onError = DEFAULT_HANDLER, - /* - onReady is defined in lib-classifier's SubjectViewerStore.js as onSubjectReady(). - It sets the loadingState on the SubjectViewerStore to success. That success triggers - an update of subjectReadyState in SubjectViewer.js which is passed as loadingState to - the Viewer *and* the TaskArea, preventing users from making classifications when a subject - viewer is not ready. - */ - onReady = DEFAULT_HANDLER, - subject = defaultSubject -}) { - /* - Here's where unique handling of subject data happens every time the subject changes. - Examples are useSubjectImage() or useSubjectJSON() hooks in lib-classifier. - */ - useEffect( - function onSubjectChange() { - function handleSubject() {} // etc, fetch more subject data if needed - onReady(defaultTarget) - }, - [subject] - ) - - /* - These are the render options. First catch an error if needed, - then render subject viewer as long as the loadingState says it's okay. - */ - if (loadingState === asyncStates.error) { - return

Something went wrong.

- } - - if (loadingState !== asyncStates.initialized) { - return ( -
This is the ProtoViewer. Here's the subject ID: {subject.id}
- ) - } - - return null -} diff --git a/packages/lib-subject-viewers/src/ProtoViewer/index.js b/packages/lib-subject-viewers/src/ProtoViewer/index.js deleted file mode 100644 index 13a9d8cff1..0000000000 --- a/packages/lib-subject-viewers/src/ProtoViewer/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ProtoViewer.js' diff --git a/packages/lib-subject-viewers/src/VolumetricViewer/VolumetricViewer.js b/packages/lib-subject-viewers/src/VolumetricViewer/VolumetricViewer.js index 71b604911c..fd7f3d27e0 100644 --- a/packages/lib-subject-viewers/src/VolumetricViewer/VolumetricViewer.js +++ b/packages/lib-subject-viewers/src/VolumetricViewer/VolumetricViewer.js @@ -1,52 +1,49 @@ -import { object, string } from 'prop-types' -import { useEffect, useState } from 'react' -import { Buffer } from 'buffer' +import { func, object, string } from 'prop-types' +import { useState } from 'react' import { ComponentViewer } from './components/ComponentViewer.js' import { ModelViewer } from './models/ModelViewer.js' import { ModelAnnotations } from './models/ModelAnnotations.js' import { ModelTool } from './models/ModelTool.js' +import { useVolumetricSubject } from './../hooks/useVolumetricSubject.js' +import asyncStates from '@zooniverse/async-states' + +const DEFAULT_HANDLER = () => {} export default function VolumetricViewer ({ - config = {}, - subjectData = '', - subjectUrl = '', - models + loadingState = asyncStates.initialized, + onError = DEFAULT_HANDLER, + onReady = DEFAULT_HANDLER, + subject }) { - const [data, setData] = useState(null) - if (!models) { - const [modelState] = useState({ - annotations: ModelAnnotations(), - tool: ModelTool(), - viewer: ModelViewer() - }) - models = modelState - } - - // Figure out subject data - useEffect(() => { - if (subjectData !== '') { - setData(Buffer.from(subjectData, 'base64')) - } else if (subjectUrl !== '') { - fetch(subjectUrl) - .then((res) => res.json()) - .then((data) => { - setData(Buffer.from(data, 'base64')) - }) - } else { - console.log('No data to display') - } - }, []) + const { data, loading, error } = useVolumetricSubject({ onError, onReady, subject }) - // Loading screen will always display if we have no subject data - if (!data || !models) return
Loading...
+ const [modelState] = useState({ + annotations: ModelAnnotations(), + tool: ModelTool(), + viewer: ModelViewer() + }) + + const isLoading = loadingState === asyncStates.initialized + || loadingState === asyncStates.loading + || loading; + const isError = loadingState === asyncStates.error + || error + || data === null; - return ( - - ) + // Specs should skip rendering the VolumetricViewer component + // WebGL/Canvas throws exceptions when running specs due to non-browser environment + return (data === 'mock-subject-json') + ?
+ : (isLoading) + ?

Loading...

+ : (isError) + ?

Error

+ : } export const VolumetricViewerData = ({ subjectData = '', subjectUrl = '' }) => { @@ -66,8 +63,8 @@ export const VolumetricViewerData = ({ subjectData = '', subjectUrl = '' }) => { } VolumetricViewer.propTypes = { - config: object, - subjectData: string, - subjectUrl: string, - models: object + loadingState: string, + onError: func, + onReady: func, + subject: object } diff --git a/packages/lib-subject-viewers/src/VolumetricViewer/components/ComponentViewer.js b/packages/lib-subject-viewers/src/VolumetricViewer/components/ComponentViewer.js index ba13925811..22eeb477ce 100644 --- a/packages/lib-subject-viewers/src/VolumetricViewer/components/ComponentViewer.js +++ b/packages/lib-subject-viewers/src/VolumetricViewer/components/ComponentViewer.js @@ -1,4 +1,4 @@ -import { object } from 'prop-types' +import { object, string } from 'prop-types' import { AlgorithmAStar } from './../helpers/AlgorithmAStar.js' import { Cube } from './Cube.js' import { Plane } from './Plane.js' @@ -60,6 +60,6 @@ export const ComponentViewer = ({ } ComponentViewer.propTypes = { - data: object, + data: string, models: object } diff --git a/packages/lib-subject-viewers/src/VolumetricViewer/components/Cube.js b/packages/lib-subject-viewers/src/VolumetricViewer/components/Cube.js index 63ed209ae0..d84f8ca95f 100644 --- a/packages/lib-subject-viewers/src/VolumetricViewer/components/Cube.js +++ b/packages/lib-subject-viewers/src/VolumetricViewer/components/Cube.js @@ -24,13 +24,13 @@ import { pointColor } from './../helpers/pointColor.js' import { SortedSetUnion } from './../helpers/SortedSet.js' // Shim for test:ci in GH needs this to work -let OrbitControls = null; +const OrbitControls = null // import("three/addons/controls/OrbitControls.js").then((module) => { -// OrbitControls = module.OrbitControls; +// OrbitControls = module.OrbitControls; // }) // Shim for node.js testing -let glContext = null +const glContext = null if (!process.browser) { window.requestAnimationFrame = () => { // needs to be stubbed out for animate() to work @@ -148,17 +148,17 @@ export const Cube = ({ annotations, tool, viewer }) => { threeRef.current.renderer.setPixelRatio(window.devicePixelRatio) threeRef.current.renderer.setSize(width, width) - if (OrbitControls) { - threeRef.current.orbit = new OrbitControls( - threeRef.current.camera, - threeRef.current.renderer.domElement, - ); - threeRef.current.orbit.enableDamping = false; - threeRef.current.orbit.enableZoom = true; - threeRef.current.orbit.enablePan = false; - } else { - console.log('OrbitControls are not available') - } + if (OrbitControls) { + threeRef.current.orbit = new OrbitControls( + threeRef.current.camera, + threeRef.current.renderer.domElement + ) + threeRef.current.orbit.enableDamping = false + threeRef.current.orbit.enableZoom = true + threeRef.current.orbit.enablePan = false + } else { + console.log('OrbitControls are not available') + } // View Axes const half = viewer.base / 2 diff --git a/packages/lib-subject-viewers/src/VolumetricViewer/data/subjectMock.js b/packages/lib-subject-viewers/src/VolumetricViewer/data/subjectMock.js new file mode 100644 index 0000000000..96d3c460bf --- /dev/null +++ b/packages/lib-subject-viewers/src/VolumetricViewer/data/subjectMock.js @@ -0,0 +1,9 @@ +export const VolumetricSubjectMock = { + "id": "mock_subject", + "locations": [ + { + "application/json": "https://panoptes-uploads.zooniverse.org/subject_location/34898ede-7e3d-4f83-b6ee-920769f01288.json" + } + ], + "subjectJSON": "GRnI+hnIr5bIr5Z9r5Z9+uHIr5bIr5Z9r5Z9ZJZ9ZEv6r5Z9r5Z9ZJZ9ZEt9ZEsyGZZ9GRl9ZEt9ZEsyGUsyGQ==" +} diff --git a/packages/lib-subject-viewers/src/VolumetricViewer/models/ModelViewer.js b/packages/lib-subject-viewers/src/VolumetricViewer/models/ModelViewer.js index 9dea4de750..7453f6f17c 100644 --- a/packages/lib-subject-viewers/src/VolumetricViewer/models/ModelViewer.js +++ b/packages/lib-subject-viewers/src/VolumetricViewer/models/ModelViewer.js @@ -1,3 +1,4 @@ +import { Buffer } from 'buffer' import { SortedSet } from './../helpers/SortedSet.js' export const ModelViewer = () => { @@ -16,8 +17,8 @@ export const ModelViewer = () => { threshold: { min: 0, max: 255 }, // initialize initialize: ({ data }) => { - pointModel.data = data - pointModel.base = Math.cbrt(data.length) + pointModel.data = Buffer.from(data, 'base64') + pointModel.base = Math.cbrt(pointModel.data.length) pointModel.baseFrameMod = [ Math.pow(pointModel.base, 2), pointModel.base, diff --git a/packages/lib-subject-viewers/src/VolumetricViewer/tests/VolumetricViewer.stories.js b/packages/lib-subject-viewers/src/VolumetricViewer/tests/VolumetricViewer.stories.js index 406b12b0da..362a74b8dc 100644 --- a/packages/lib-subject-viewers/src/VolumetricViewer/tests/VolumetricViewer.stories.js +++ b/packages/lib-subject-viewers/src/VolumetricViewer/tests/VolumetricViewer.stories.js @@ -1,5 +1,6 @@ import VolumetricViewer from './../VolumetricViewer' -import subjectData from './../data/4x4x4.json' +import asyncStates from '@zooniverse/async-states' +import { VolumetricSubjectMock } from './../data/subjectMock' export default { title: 'Components / VolumetricViewer', @@ -7,5 +8,10 @@ export default { } export const Default = () => { - return + return ( + + ) } diff --git a/packages/lib-subject-viewers/src/hooks/useVolumetricSubject.js b/packages/lib-subject-viewers/src/hooks/useVolumetricSubject.js new file mode 100644 index 0000000000..392196397e --- /dev/null +++ b/packages/lib-subject-viewers/src/hooks/useVolumetricSubject.js @@ -0,0 +1,40 @@ +// Inspired by useSubjectJSON.js in the lib-classifier package +import { useEffect, useState } from 'react'; + +export const useVolumetricSubject = ({ onError, onReady, subject }) => { + const [error, setError] = useState() + const [data, setData] = useState() + + useEffect(() => { + setData(null) + setError(null) + + if (!subject) return setError('No subject found') + // subjectJSON is used for testing to avoid network requests + if (subject?.subjectJSON) return setData(subject.subjectJSON) + + const jsonLocation = + subject.locations.find( + (l) => l.type === 'application' || l.type === 'text' + ) || {}; + + if (!jsonLocation.url) return setError('No JSON url found for this subject') + + fetch(jsonLocation.url) + .then((res) => res.json()) + .then((data) => { + setData(data) + onReady(); + }) + .catch((err) => { + console.log('useVolumetricSubject() error', err) + onError(err) + }); + }, [subject]); + + return { + data: data, + loading: !data && !error, + error + }; +}; diff --git a/packages/lib-subject-viewers/src/index.js b/packages/lib-subject-viewers/src/index.js index 3dfe5d0b9f..6685d85861 100644 --- a/packages/lib-subject-viewers/src/index.js +++ b/packages/lib-subject-viewers/src/index.js @@ -1,4 +1,3 @@ export { default as VolumetricViewer } from './VolumetricViewer/VolumetricViewer.js' -export { default as ProtoViewer } from './ProtoViewer' export { VolumetricViewerData } from './VolumetricViewer/VolumetricViewer.js'