Skip to content

Commit

Permalink
progress towards using Fluent for accessibility strings, see phetsims…
Browse files Browse the repository at this point in the history
  • Loading branch information
jessegreenberg committed Nov 15, 2024
1 parent bbec765 commit f39a6bb
Show file tree
Hide file tree
Showing 10 changed files with 297 additions and 75 deletions.
125 changes: 125 additions & 0 deletions js/OhmsLawFluentMessages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright 2024, University of Colorado Boulder

/**
* Prototype for preparing fluent strings for Ohm's Law. Fluent strings are loaded in a preload script. They are then
* processed into Properties so that they can be used in the simulation.
*
* This is for a proof of concept. By creating Properties, we get a good sense of what usages would be like in simulation
* code. But it is a partial implementation. The full solution needs to consider PhET-iO control, and be more integrated
* into PhET's string modules.
*
* Classes in this file support converting fluent data types into Properties and strings. Fluent has the following concepts:
* - Bundle: A collection of messages for a single locale.
* - Message: An data structure in a FluentBundle. The message can be formatted with arguments into a final string.
* If there are no arguments, the message is a string.
*
* @author Jesse Greenberg (PhET Interactive Simulations)
*/

import DerivedProperty from '../../axon/js/DerivedProperty.js';
import { isTReadOnlyProperty } from '../../axon/js/TReadOnlyProperty.js';
import localeProperty from '../../joist/js/i18n/localeProperty.js';

// TODO: Create a bundle for every supported locale, https://github.com/phetsims/joist/issues/992
const englishBundle = new Fluent.FluentBundle( 'en' );
const bundleMap = new Map();

bundleMap.set( 'en', englishBundle );

// Currently, we pre-load english strings. Each bundle needs to load its own language.
const resource = Fluent.FluentResource.fromString( phet.chipper.fluentStrings );
englishBundle.addResource( resource );

/**
* A Property that provides the current bundle for the current locale.
* TODO: We should only need one instance of this, right? Could be more efficient, https://github.com/phetsims/joist/issues/992
*/
class LocalizedBundleProperty extends DerivedProperty {
constructor( id ) {

// Just to get Property interface working, but this needs to be bi-directional
// and use LocalizedString/LocalizedStringProperty.
super( [ localeProperty ], locale => {

// fall back to the english bundle
const bundleToUse = bundleMap.has( locale ) ? bundleMap.get( locale ) : englishBundle;

// Fall back to the english bundle if the bundleToUse does not have the message.
return bundleToUse.hasMessage( id ) ? bundleToUse : englishBundle;
} );
}
}

/**
* A Property whose value is a message from a bundle.
*/
class LocalizedMessageProperty extends DerivedProperty {
constructor( bundleProperty, id ) {

// Just to get Property interface working, but this needs to be bi-directional
// and use LocalizedString/LocalizedStringProperty stack.
super( [ bundleProperty ], locale => {
return bundleProperty.value.getMessage( id );
} );

this.bundleProperty = bundleProperty;
}
}

/**
* A Property whose value is a message from a bundle with arguments. Each argument can be a Property,
* and the message will be updated either the message or the argument changes..
*/
class PatternMessageProperty extends DerivedProperty {
constructor( messageProperty, values ) {
const dependencies = [ messageProperty ];
const keys = Object.keys( values );
keys.forEach( key => {
if ( isTReadOnlyProperty( values[ key ] ) ) {
dependencies.push( values[ key ] );
}
} );

super( dependencies, ( message, ...unusedArgs ) => {
const args = {};
keys.forEach( key => {
let value = values[ key ];

// If the value is a Property, get the value.
if ( isTReadOnlyProperty( value ) ) {
value = value.value;
}

// If the value is an EnumerationValue, automatically use the enum name.
if ( value && value.name ) {
value = value.name;
}

args[ key ] = value;
} );

// Format the message with the arguments to resolve a string.
return messageProperty.bundleProperty.value.format( message, args );
} );
}
}

/**
* Converts a camelCase id to a message key. For example, 'choose-unit-for-current' becomes
* 'chooseUnitForCurrentMessageProperty'.
*/
const fluentIdToMessageKey = id => {
return `${id.replace( /-([a-z])/g, ( match, letter ) => letter.toUpperCase() )}MessageProperty`;
};

const OhmsLawFluentMessages = {};
for ( const [ id ] of englishBundle.messages ) {

// So that you can look up fluent strings with camelCase.
OhmsLawFluentMessages[ fluentIdToMessageKey( id ) ] = new LocalizedMessageProperty(
new LocalizedBundleProperty( id ), id
);
}

export default OhmsLawFluentMessages;
export { PatternMessageProperty };
33 changes: 33 additions & 0 deletions js/load-unbuilt-fluent-strings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright 2024, University of Colorado Boulder

/**
* A prototype for loading unbuilt fluent strings. Meant to be a preload
* script, pulling in the fluent strings before the simulation is loaded.
*
* This will need to be integrated into PhET's string modules in a more
* robust way.
*
* @author Jesse Greenberg (PhET Interactive Simulations)
*/

( () => {
window.phet = window.phet || {};
window.phet.chipper = window.phet.chipper || {};

// Constructing the string map
window.phet.chipper.fluentStrings = {};

const requestFluentFile = () => {
const xhr = new XMLHttpRequest();
xhr.open( 'GET', 'ohms-law-strings_en.ftl', true );
xhr.responseType = 'text';
xhr.onload = () => {
if ( xhr.status === 200 ) {
window.phet.chipper.fluentStrings = xhr.response;
}
};
xhr.send();
};

requestFluentFile();
} )();
27 changes: 27 additions & 0 deletions js/ohms-law/view/OhmsLawDescriber.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
* @author Michael Kauzmann (PhET Interactive Simulations)
*/

import DerivedProperty from '../../../../axon/js/DerivedProperty.js';
import EnumerationValue from '../../../../phet-core/js/EnumerationValue.js';
import Enumeration from '../../../../phet-core/js/Enumeration.js';
import StringUtils from '../../../../phetcommon/js/util/StringUtils.js';
import Utils from '../../../../dot/js/Utils.js';
import OhmsLawConstants from '../OhmsLawConstants.js';
import ohmsLaw from '../../ohmsLaw.js';
import CurrentUnit from '../model/CurrentUnit.js';
import OhmsLawA11yStrings from '../OhmsLawA11yStrings.js';
Expand All @@ -15,6 +20,18 @@ const currentMilliampsString = OhmsLawA11yStrings.currentMilliamps.value;
const currentAmpsString = OhmsLawA11yStrings.currentAmps.value;
const sliderChangeAlertPatternString = OhmsLawA11yStrings.sliderChangeAlertPattern.value;

// enum for describing resistance impurities
export class ResistorImpurities extends EnumerationValue {
static TINY = new ResistorImpurities();
static VERY_SMALL = new ResistorImpurities();
static SMALL = new ResistorImpurities();
static MEDIUM = new ResistorImpurities();
static LARGE = new ResistorImpurities();
static VERY_LARGE = new ResistorImpurities();
static HUGE = new ResistorImpurities();
static enumeration = new Enumeration( ResistorImpurities );
}

class OhmsLawDescriber {

/**
Expand All @@ -24,6 +41,16 @@ class OhmsLawDescriber {

// @private
this.model = model;

// @public - Enumeration value describing the impurities in the resistor
this.resistorImpuritiesProperty = new DerivedProperty( [ model.resistanceProperty ], resistance => {
const values = ResistorImpurities.enumeration.values;
const range = OhmsLawConstants.RESISTANCE_RANGE;

// map the normalied value to one of the resistance descriptions
const index = Utils.roundSymmetric( Utils.linear( range.min, range.max, 0, values.length - 1, resistance ) );
return values[ index ];
} );
}

/**
Expand Down
52 changes: 20 additions & 32 deletions js/ohms-law/view/OhmsLawScreenSummaryNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,14 @@
* @author Jesse Greenberg
*/

import Multilink from '../../../../axon/js/Multilink.js';
import DerivedProperty from '../../../../axon/js/DerivedProperty.js';
import Utils from '../../../../dot/js/Utils.js';
import ScreenSummaryContent from '../../../../joist/js/ScreenSummaryContent.js';
import StringUtils from '../../../../phetcommon/js/util/StringUtils.js';
import { Node } from '../../../../scenery/js/imports.js';
import ohmsLaw from '../../ohmsLaw.js';
import OhmsLawA11yStrings from '../OhmsLawA11yStrings.js';
import OhmsLawFluentMessages, { PatternMessageProperty } from '../../OhmsLawFluentMessages.js';
import OhmsLawConstants from '../OhmsLawConstants.js';

const summaryLookForSlidersString = OhmsLawA11yStrings.summaryLookForSliders.value;
const summaryPlayAreaString = OhmsLawA11yStrings.summaryPlayArea.value;
const summaryControlAreaString = OhmsLawA11yStrings.summaryControlArea.value;
const rightNowString = OhmsLawA11yStrings.rightNow.value;
const voltageSummaryPatternString = OhmsLawA11yStrings.voltageSummaryPattern.value;
const resistanceSummaryPatternString = OhmsLawA11yStrings.resistanceSummaryPattern.value;
const currentSummaryPatternString = OhmsLawA11yStrings.currentSummaryPattern.value;

class OhmsLawScreenSummaryNode extends ScreenSummaryContent {

/**
Expand All @@ -32,9 +23,9 @@ class OhmsLawScreenSummaryNode extends ScreenSummaryContent {
*/
constructor( model, ohmsLawDescriber ) {
super( [
summaryPlayAreaString,
summaryControlAreaString,
rightNowString
OhmsLawFluentMessages.summaryPlayAreaMessageProperty,
OhmsLawFluentMessages.summaryControlAreaMessageProperty,
OhmsLawFluentMessages.rightNowMessageProperty
] );

// list outlining the values for this sim
Expand All @@ -44,7 +35,7 @@ class OhmsLawScreenSummaryNode extends ScreenSummaryContent {
const valueCurrentItemNode = new Node( { tagName: 'li' } );
valueListNode.children = [ valueVoltageItemNode, valueResistanceItemNode, valueCurrentItemNode ];

const sliderParagraphNode = new Node( { tagName: 'p', innerContent: summaryLookForSlidersString } );
const sliderParagraphNode = new Node( { tagName: 'p', innerContent: OhmsLawFluentMessages.summaryLookForSlidersMessageProperty } );

// add all children to this node, ordering the accessible content
this.addChild( valueListNode );
Expand All @@ -55,34 +46,31 @@ class OhmsLawScreenSummaryNode extends ScreenSummaryContent {
[
{
property: model.voltageProperty,
patternString: voltageSummaryPatternString,

patternStringProperty: OhmsLawFluentMessages.voltageSummaryPatternMessageProperty,
node: valueVoltageItemNode,
precision: OhmsLawConstants.VOLTAGE_SIG_FIGS
},
{
property: model.resistanceProperty,
patternString: resistanceSummaryPatternString,
patternStringProperty: OhmsLawFluentMessages.resistanceSummaryPatternMessageProperty,
node: valueResistanceItemNode,
precision: OhmsLawConstants.RESISTANCE_SIG_FIGS
}
].forEach( item => {

// register listeners that update the labels in the screen summary - this summary exists for life of sim,
// no need to dispose
item.property.link( value => {
item.node.innerContent = StringUtils.fillIn( item.patternString, {
value: Utils.toFixed( value, item.precision ),
unit: ohmsLawDescriber.getUnitForCurrent()
} );
} );
item.node.innerContent = new PatternMessageProperty(
item.patternStringProperty, {
value: new DerivedProperty( [ item.property ], value => Utils.toFixed( value, item.precision ) )
}
);
} );

Multilink.multilink( [ model.currentProperty, model.currentUnitsProperty ], ( current, units ) => {
valueCurrentItemNode.innerContent = StringUtils.fillIn( currentSummaryPatternString, {
value: model.getFixedCurrent(),
unit: ohmsLawDescriber.getUnitForCurrent()
} );
} );
valueCurrentItemNode.innerContent = new PatternMessageProperty(
OhmsLawFluentMessages.currentSummaryPatternMessageProperty, {
value: new DerivedProperty( [ model.currentProperty ], value => model.getFixedCurrent() ),
unit: model.currentUnitsProperty
}
);
}
}

Expand Down
44 changes: 9 additions & 35 deletions js/ohms-law/view/ResistorNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,12 @@ import LinearFunction from '../../../../dot/js/LinearFunction.js';
import Utils from '../../../../dot/js/Utils.js';
import { Shape } from '../../../../kite/js/imports.js';
import merge from '../../../../phet-core/js/merge.js';
import StringUtils from '../../../../phetcommon/js/util/StringUtils.js';
import { Circle, LinearGradient, Node, Path } from '../../../../scenery/js/imports.js';
import Tandem from '../../../../tandem/js/Tandem.js';
import ohmsLaw from '../../ohmsLaw.js';
import OhmsLawA11yStrings from '../OhmsLawA11yStrings.js';
import OhmsLawFluentMessages, { PatternMessageProperty } from '../../OhmsLawFluentMessages.js';
import OhmsLawConstants from '../OhmsLawConstants.js';

const tinyAmountOfImpuritiesString = OhmsLawA11yStrings.tinyAmountOfImpurities.value;
const verySmallAmountOfImpuritiesString = OhmsLawA11yStrings.verySmallAmountOfImpurities.value;
const smallAmountOfImpuritiesString = OhmsLawA11yStrings.smallAmountOfImpurities.value;
const mediumAmountOfImpuritiesString = OhmsLawA11yStrings.mediumAmountOfImpurities.value;
const largeAmountOfImpuritiesString = OhmsLawA11yStrings.largeAmountOfImpurities.value;
const veryLargeAmountOfImpuritiesString = OhmsLawA11yStrings.veryLargeAmountOfImpurities.value;
const hugeAmountOfImpuritiesString = OhmsLawA11yStrings.hugeAmountOfImpurities.value;
const resistanceDotsPatternString = OhmsLawA11yStrings.resistanceDotsPattern.value;

// constants
const RESISTOR_WIDTH = OhmsLawConstants.WIRE_WIDTH / 2.123; // empirically determined
const RESISTOR_HEIGHT = OhmsLawConstants.WIRE_HEIGHT / 2.75; // empirically determined
Expand All @@ -37,9 +27,6 @@ const MAX_WIDTH_INCLUDING_ROUNDED_ENDS = RESISTOR_WIDTH + RESISTOR_HEIGHT * PERS
const DOT_RADIUS = 2;
const AREA_PER_DOT = 40; // adjust this to control the density of the dots
const NUMBER_OF_DOTS = MAX_WIDTH_INCLUDING_ROUNDED_ENDS * RESISTOR_HEIGHT / AREA_PER_DOT;
const IMPURITIES_STRINGS = [ tinyAmountOfImpuritiesString, verySmallAmountOfImpuritiesString, smallAmountOfImpuritiesString,
mediumAmountOfImpuritiesString, largeAmountOfImpuritiesString, veryLargeAmountOfImpuritiesString,
hugeAmountOfImpuritiesString ];

const BODY_FILL_GRADIENT = new LinearGradient( 0, -RESISTOR_HEIGHT / 2, 0, RESISTOR_HEIGHT / 2 ) // For 3D effect on the wire.
.addColorStop( 0, '#F00' )
Expand All @@ -63,9 +50,10 @@ const RESISTANCE_TO_NUM_DOTS = new LinearFunction(
class ResistorNode extends Node {
/**
* @param {Property.<number>} resistanceProperty
* @param {TReadOnlyProperty<ResistorImpurities>}resistorImpuritiesProperty
* @param {Object} [options]
*/
constructor( resistanceProperty, options ) {
constructor( resistanceProperty, resistorImpuritiesProperty, options ) {

options = merge( {
tandem: Tandem.REQUIRED,
Expand Down Expand Up @@ -135,29 +123,15 @@ class ResistorNode extends Node {
dotsNode.children.forEach( ( dot, index ) => {
dot.setVisible( index < numDotsToShow );
} );

this.innerContent = this.getResistanceDescription( resistance );
} );

this.mutate( options );
}

this.innerContent = new PatternMessageProperty(
OhmsLawFluentMessages.resistanceDotsPatternMessageProperty, {
impurities: resistorImpuritiesProperty
}
);

/**
* Get a description of the resistance based on the value of the resistance.
* @returns {string} resistance
* @private
*/
getResistanceDescription( resistance ) {
const range = OhmsLawConstants.RESISTANCE_RANGE;

// map the normalied value to one of the resistance descriptions
const index = Utils.roundSymmetric( Utils.linear( range.min, range.max, 0, IMPURITIES_STRINGS.length - 1, resistance ) );
const numDotsDescription = IMPURITIES_STRINGS[ index ];

return StringUtils.fillIn( resistanceDotsPatternString, {
impurities: numDotsDescription
} );
this.mutate( options );
}
}

Expand Down
Loading

0 comments on commit f39a6bb

Please sign in to comment.