From 0de767289d61b94a5039edcff18b3d3862b48b4f Mon Sep 17 00:00:00 2001 From: Chris Joel Date: Thu, 31 Jan 2019 10:20:26 -0800 Subject: [PATCH] Announce camera orientation to screen readers --- examples/index.html | 1 - src/features/controls.js | 45 ++++++++++++- src/test/features/controls-spec.js | 64 ++++++++++++++++++- .../three-components/SmoothControls-spec.js | 2 +- 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/examples/index.html b/examples/index.html index d1903e0893..a2e6ae399f 100644 --- a/examples/index.html +++ b/examples/index.html @@ -69,7 +69,6 @@

Demos & Scenarios

src="assets/Astronaut.glb" alt="A 3D model of an astronaut" controls - auto-rotate background-color="#455A64"> diff --git a/src/features/controls.js b/src/features/controls.js index 527ff4fd4f..66e464466c 100644 --- a/src/features/controls.js +++ b/src/features/controls.js @@ -13,12 +13,20 @@ * limitations under the License. */ -import {PerspectiveCamera, Vector3} from 'three'; +import {PerspectiveCamera, Spherical, Vector3} from 'three'; import {$ariaLabel, $needsRender, $onModelLoad, $onResize, $scene, $tick} from '../model-viewer-base.js'; import {FRAMED_HEIGHT} from '../three-components/ModelScene.js'; import {SmoothControls} from '../three-components/SmoothControls.js'; +const HALF_PI = Math.PI / 2.0; +const THIRD_PI = Math.PI / 3.0; +const QUARTER_PI = HALF_PI / 2.0; +const PHI = 2.0 * Math.PI; + +const AZIMUTHAL_QUADRANT_LABELS = ['front', 'right', 'back', 'left']; +const POLAR_TRIENT_LABELS = ['upper-', '', 'lower-']; + const ORBIT_NEAR_PLANE = 0.01; const ORBIT_FAR_PLANE = 1000; @@ -43,6 +51,8 @@ const $waitingToPromptUser = Symbol('waitingToPromptUser'); const $userPromptedOnce = Symbol('userPromptedOnce'); const $idleTime = Symbol('idleTime'); +const $lastSpherical = Symbol('lastSpherical'); + export const $promptElement = Symbol('promptElement'); export const ControlsMixin = (ModelViewerElement) => { @@ -69,6 +79,8 @@ export const ControlsMixin = (ModelViewerElement) => { this[$orbitCamera].updateProjectionMatrix(); this[$controls] = null; + this[$lastSpherical] = new Spherical(); + this[$changeHandler] = () => this[$onChange](); this[$focusHandler] = () => this[$onFocus](); this[$blurHandler] = () => this[$onBlur](); @@ -170,8 +182,10 @@ export const ControlsMixin = (ModelViewerElement) => { // original, non-prompt label if appropriate. If the user has already // interacted, they no longer need to hear the prompt. Otherwise, they // will hear it again after the idle prompt threshold has been crossed. - if (canvas.getAttribute('aria-label') === IDLE_PROMPT) { - canvas.setAttribute('aria-label', this[$ariaLabel]); + const ariaLabel = this[$ariaLabel]; + + if (canvas.getAttribute('aria-label') !== ariaLabel) { + canvas.setAttribute('aria-label', ariaLabel); } // NOTE(cdata): When focused, if the user has yet to interact with the @@ -202,6 +216,31 @@ export const ControlsMixin = (ModelViewerElement) => { if (this[$userPromptedOnce]) { this[$shouldPromptUserToInteract] = false; } + + const {theta: lastTheta, phi: lastPhi} = this[$lastSpherical]; + const {theta, phi} = + this[$controls].getCameraSpherical(this[$lastSpherical]); + + const lastAzimuthalQuadrant = + (4 + Math.floor(((lastTheta % PHI) + QUARTER_PI) / HALF_PI)) % 4; + const azimuthalQuadrant = + (4 + Math.floor(((theta % PHI) + QUARTER_PI) / HALF_PI)) % 4; + + const lastPolarTrient = Math.floor(lastPhi / THIRD_PI); + const polarTrient = Math.floor(phi / THIRD_PI); + + if (azimuthalQuadrant !== lastAzimuthalQuadrant || + polarTrient !== lastPolarTrient) { + const {canvas} = this[$scene]; + const azimuthalQuadrantLabel = + AZIMUTHAL_QUADRANT_LABELS[azimuthalQuadrant]; + const polarTrientLabel = POLAR_TRIENT_LABELS[polarTrient]; + + const ariaLabel = + `View from stage ${polarTrientLabel}${azimuthalQuadrantLabel}`; + + canvas.setAttribute('aria-label', ariaLabel); + } } }; }; diff --git a/src/test/features/controls-spec.js b/src/test/features/controls-spec.js index ff5fd58b4d..25a0dafcd8 100644 --- a/src/test/features/controls-spec.js +++ b/src/test/features/controls-spec.js @@ -17,6 +17,8 @@ import {$controls, $promptElement, ControlsMixin, IDLE_PROMPT, IDLE_PROMPT_THRES import ModelViewerElementBase, {$scene} from '../../model-viewer-base.js'; import {assetPath, dispatchSyntheticEvent, rafPasses, timePasses, until, waitForEvent} from '../helpers.js'; +import {settleControls} from '../three-components/SmoothControls-spec.js'; + const expect = chai.expect; const interactWith = (element) => { @@ -85,6 +87,10 @@ suite('ModelViewerElementBase with ControlsMixin', () => { }); suite('a11y', () => { + setup(async () => { + await rafPasses(); + }); + test('prompts user to interact when focused', async () => { const {canvas} = element[$scene]; const promptElement = element[$promptElement]; @@ -109,6 +115,8 @@ suite('ModelViewerElementBase with ControlsMixin', () => { const promptElement = element[$promptElement]; const originalLabel = canvas.getAttribute('aria-label'); + expect(originalLabel).to.not.be.equal(IDLE_PROMPT); + canvas.focus(); await timePasses(); @@ -117,11 +125,63 @@ suite('ModelViewerElementBase with ControlsMixin', () => { await timePasses(IDLE_PROMPT_THRESHOLD_MS + 100); - expect(canvas.getAttribute('aria-label')).to.be.equal(originalLabel); - + expect(canvas.getAttribute('aria-label')) + .to.not.be.equal(IDLE_PROMPT); expect(promptElement.classList.contains('visible')) .to.be.equal(false); }); + + test('announces camera orientation when orbiting horizontally', () => { + const {canvas} = element[$scene]; + const controls = element[$controls]; + + controls.setOrbit(-Math.PI / 2.0); + settleControls(controls); + + expect(canvas.getAttribute('aria-label')) + .to.be.equal('View from stage left'); + + controls.setOrbit(Math.PI / 2.0); + settleControls(controls); + + expect(canvas.getAttribute('aria-label')) + .to.be.equal('View from stage right'); + + controls.adjustOrbit(-Math.PI / 2.0, 0, 0); + settleControls(controls); + + expect(canvas.getAttribute('aria-label')) + .to.be.equal('View from stage back'); + + controls.adjustOrbit(Math.PI, 0, 0); + settleControls(controls); + + expect(canvas.getAttribute('aria-label')) + .to.be.equal('View from stage front'); + }); + + test('announces camera orientation when orbiting vertically', () => { + const {canvas} = element[$scene]; + const controls = element[$controls]; + + controls.setOrbit(0, 0); + settleControls(controls); + + expect(canvas.getAttribute('aria-label')) + .to.be.equal('View from stage upper-front'); + + controls.adjustOrbit(0, -Math.PI / 2.0, 0); + settleControls(controls); + + expect(canvas.getAttribute('aria-label')) + .to.be.equal('View from stage front'); + + controls.adjustOrbit(0, -Math.PI / 2.0, 0); + settleControls(controls); + + expect(canvas.getAttribute('aria-label')) + .to.be.equal('View from stage lower-front'); + }); }); }); }); diff --git a/src/test/three-components/SmoothControls-spec.js b/src/test/three-components/SmoothControls-spec.js index 834908d503..152b057927 100644 --- a/src/test/three-components/SmoothControls-spec.js +++ b/src/test/three-components/SmoothControls-spec.js @@ -53,7 +53,7 @@ const cameraIsLookingAt = (camera, position) => { /** * Settle controls by performing 50 frames worth of updates */ -const settleControls = controls => +export const settleControls = controls => controls.update(performance.now, FIFTY_FRAME_DELTA); suite('SmoothControls', () => {