diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..941fcf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +deno.lock \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f393838..8675ad5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,4 @@ { "deno.enable": true, - "deno.unstable": true, - "deno.codeLens.testArgs": [ - "--allow-all", - "--no-check", - "--no-lock" - ] + "deno.unstable": true } \ No newline at end of file diff --git a/deno.json b/deno.json index 24d03de..538cf72 100644 --- a/deno.json +++ b/deno.json @@ -1,8 +1,9 @@ { - "tasks": - { - "fs": "deno run -A --no-lock https://deno.land/std@0.166.0/http/file_server.ts", - "test": "deno test --no-lock --watch test/store_test.js", - "test-debug": "deno test --no-lock --inspect-brk test/store_test.js" - } + "compilerOptions": { "types": ["*.d.ts"], "checkJs": true }, + "imports": { + "@twind/": "https://esm.sh/@twind/", + "react": "https://esm.sh/preact@10.11.3/compat", + "htm": "https://esm.sh/htm@3.1.1/preact", + "app": "./js/app.js" + } } \ No newline at end of file diff --git a/index.html b/index.html index 601fcf6..c9e0a2a 100644 --- a/index.html +++ b/index.html @@ -1,2 +1,12 @@ -
- \ No newline at end of file + + + + + + +
+ + + + + \ No newline at end of file diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..1c84348 --- /dev/null +++ b/js/app.js @@ -0,0 +1,34 @@ +import React from "react"; +import {html} from "htm"; +import * as TW from "./twind.js"; +import * as UI from "./ui.js"; +import * as Store from "./store.js"; + +// @ts-ignore: +const ShadowDOM = document.querySelector("#app").attachShadow({mode: "open"}); +const ShadowDiv = document.createElement("div"); +const ShadowCSS = document.createElement("style"); +ShadowDOM.append(ShadowCSS); +ShadowDOM.append(ShadowDiv); + +TW.Init(ShadowCSS, ShadowDiv); + +React.render(html` + <${Store.Provider}> +
+ + <${UI.Header}/> + +
+ <${UI.Controls}/> + <${UI.Chart}> + <${UI.Audiogram}/> +
<${UI.Display}/>
+ +
+ + + +
+ +`, ShadowDiv); \ No newline at end of file diff --git a/js/store.js b/js/store.js new file mode 100644 index 0000000..1d4fa3b --- /dev/null +++ b/js/store.js @@ -0,0 +1,354 @@ +import React from "react"; + +const size = 1/6; +/** @type {Array} */ +export const ColumnMapping = [ + [ 125, size*0.0, true ], + [ 250, size*1.0, true ], + [ 500, size*2.0, true ], + [1000, size*3.0, true ], + [2000, size*4.0, true ], + [3000, size*4.5, false], + [4000, size*5.0, true ], + [6000, size*5.5, false], + [8000, size*6.0, true ] +]; +/** Looks up a frequency in ColumnMapping + * @type {(inFrequency:number)=>Store.ColumnMapping|false} */ +export const ColumnLookup =(inFrequency)=> +{ + for(let i=0; istring} */ +const Perc =(inDecimal)=> `${inDecimal*100}%`; + +/** @type {(inTest:Store.Test)=>Store.Grade} */ +export const Grade =(inTest)=> +{ + /** @type {Store.Grade} */ + const output = { Total:0, Marks:0, Score:0 }; + + /** @type {(inGoal:number, inResult:number)=>number} */ + const Mapper =(inGoal, inResult)=> + { + const err = Math.abs(inGoal-inResult); + if(err == 0){ return 1; } + else if(err > 0 && err <= 5){ return 0.9; } + else if(err > 5 && err <= 10){ return 0.7; } + else if(err > 10 && err <= 15){ return 0.2; } + else{ return 0; } + } + + + for(let i=0; i 0) + { + output.Score = Math.floor((output.Score/output.Marks) * 10000)/100; + } + + return output; + +} + +/** Creates a new Store.Context object that contain the current selections + * @type {(inState:Store.State, inTest?:Store.Test)=>Store.Context} */ +const Reselect =(inState, inTest)=> +{ + /** @type {Store.Context} */ + const output = { Test:inTest??inState.Live.Test }; + const column = ColumnMapping[inState.Freq.Value]; + if(column && output.Test) + { + const hz = column[0]; + for(let i=0; iStore.DrawGroup} */ +const Redraw =(inTest, inChan, inStim, inIsUser)=> +{ + /** @type {Store.DrawGroup} */ + const output = {Points:[], Paths:[]}; + + if(inTest) + { + let plot; + for(let i=0; iStore.DrawPoint} */ +const Reposition =(inState)=> ({ + X: Perc(ColumnMapping[inState.Freq.Value][1]), + Y: Perc((inState.Stim.Value - inState.Stim.Min)/(inState.Stim.Max - inState.Stim.Min)) +}); + +/** @type {Store.Reducer} */ +export function Reducer(inState, inAction) +{ + const clone = {...inState}; + const {Name, Data} = inAction; + + if(Name == "Test") + { + const test = clone.Test[Data]; + if(test) + { + clone.Pick = Data; + clone.Live = Reselect(clone, test); + clone.Draw = + { + Cross: Reposition(clone), + UserL: Redraw(test, 0, clone.Stim, true ), + UserR: Redraw(test, 1, clone.Stim, true ), + TestL: Redraw(test, 0, clone.Stim, false), + TestR: Redraw(test, 1, clone.Stim, false) + }; + test.Done = Grade(test); + } + } + else if (Name == "Mark") + { + if(clone.Live.Test && clone.Live.Freq) + { + const key = clone.Chan.Value == 0 ? "UserL" : "UserR"; + clone.Live.Mark = Data !== null ? {Stim:clone.Stim.Value, Resp:Data} : undefined; + clone.Live.Freq[key] = clone.Live.Mark; + clone.Draw[key] = Redraw(clone.Live.Test, clone.Chan.Value, clone.Stim, true); + clone.Live.Test.Done = Grade(clone.Live.Test); + SaveTests(clone); + } + } + else if( Name=="Stim" || Name=="Chan" || Name=="Freq") + { + const tone = clone[Name]; + tone.Value += Data*tone.Step; + tone.Value = Math.max(tone.Value, tone.Min); + tone.Value = Math.min(tone.Value, tone.Max); + + clone.Draw.Cross = Reposition(clone); + if(Name != "Stim") + { + clone.Live = Reselect(clone); + } + } + else if (Name == "Errs") + { + clone.Errs = Data; + } + else if (Name == "Kill") + { + if(clone.Live.Test) + { + clone.Live.Test.Plot.forEach(freq=> + { + freq.UserL = undefined; + freq.UserR = undefined; + }); + clone.Draw["UserL"] = Redraw(clone.Live.Test, clone.Chan.Value, clone.Stim, true); + clone.Draw["UserR"] = Redraw(clone.Live.Test, clone.Chan.Value, clone.Stim, true); + clone.Live.Test.Done = Grade(clone.Live.Test); + SaveTests(clone); + } + } + else if (Name == "ShowCursor") + { + clone.Show.Cursor = Data; + } + else if (Name == "ShowAnswer") + { + clone.Show.Answer = Data; + } + + SaveSettings(clone); + + return clone; +} + + +/** @type {Store.Test[]} */ +const TestDefault = [ + { + Name: "Patient A Asymmetric Notch", + Plot: + [ + { Hz: 500, TestL: { Stim: 15, Resp: true }, TestR: { Stim: 10, Resp: true } }, + { Hz: 1000, TestL: { Stim: 10, Resp: true }, TestR: { Stim: 10, Resp: true } }, + { Hz: 2000, TestL: { Stim: 15, Resp: true }, TestR: { Stim: 20, Resp: true } }, + { Hz: 3000, TestL: { Stim: 30, Resp: true }, TestR: { Stim: 40, Resp: true } }, + { Hz: 4000, TestL: { Stim: 40, Resp: true }, TestR: { Stim: 55, Resp: true } }, + { Hz: 6000, TestL: { Stim: 35, Resp: true }, TestR: { Stim: 40, Resp: true } }, + { Hz: 8000, TestL: { Stim: 20, Resp: true }, TestR: { Stim: 15, Resp: true } } + ] + }, + { + Name: "Patient B High Freq Hearing Loss", + Plot: + [ + { Hz: 500, TestL: { Stim: 10, Resp: true }, TestR: { Stim: 10, Resp: true } }, + { Hz: 1000, TestL: { Stim: 15, Resp: true }, TestR: { Stim: 10, Resp: true } }, + { Hz: 2000, TestL: { Stim: 10, Resp: true }, TestR: { Stim: 15, Resp: true } }, + { Hz: 3000, TestL: { Stim: 25, Resp: true }, TestR: { Stim: 20, Resp: true } }, + { Hz: 4000, TestL: { Stim: 35, Resp: true }, TestR: { Stim: 35, Resp: true } }, + { Hz: 6000, TestL: { Stim: 50, Resp: true }, TestR: { Stim: 55, Resp: true } }, + { Hz: 8000, TestL: { Stim: 80, Resp: true }, TestR: { Stim: 75, Resp: true } } + ] + }, + { + Name: "Patient C Unilateral Hearing Loss", + Plot: + [ + { Hz: 500, TestL: { Stim: 15, Resp: true }, TestR: { Stim: 40, Resp: true } }, + { Hz: 1000, TestL: { Stim: 15, Resp: true }, TestR: { Stim: 50, Resp: true } }, + { Hz: 2000, TestL: { Stim: 20, Resp: true }, TestR: { Stim: 65, Resp: true } }, + { Hz: 3000, TestL: { Stim: 15, Resp: true }, TestR: { Stim: 70, Resp: true } }, + { Hz: 4000, TestL: { Stim: 20, Resp: true }, TestR: { Stim: 65, Resp: true } }, + { Hz: 6000, TestL: { Stim: 25, Resp: true }, TestR: { Stim: 60, Resp: true } }, + { Hz: 8000, TestL: { Stim: 20, Resp: true }, TestR: { Stim: 45, Resp: true } } + ] + }, + { + Name: "Patient D Normal Hearing", + Plot: + [ + { Hz: 500, TestL: { Stim: 5, Resp: true }, TestR: { Stim: 10, Resp: true } }, + { Hz: 1000, TestL: { Stim: 0, Resp: true }, TestR: { Stim: 5, Resp: true } }, + { Hz: 2000, TestL: { Stim: 5, Resp: true }, TestR: { Stim: 5, Resp: true } }, + { Hz: 3000, TestL: { Stim: 15, Resp: true }, TestR: { Stim: 10, Resp: true } }, + { Hz: 4000, TestL: { Stim: 15, Resp: true }, TestR: { Stim: 15, Resp: true } }, + { Hz: 6000, TestL: { Stim: 5, Resp: true }, TestR: { Stim: 10, Resp: true } }, + { Hz: 8000, TestL: { Stim: 0, Resp: true }, TestR: { Stim: 5, Resp: true } } + ] + } +]; +/** @type {Store.Test[]} */ +const TestActual = JSON.parse(localStorage.getItem("app-tests")||"false") || TestDefault; +/**@type {(inState:Store.State)=>void} */ +const SaveTests =(inState)=> localStorage.setItem("app-tests", JSON.stringify(inState.Test)); + +/** @type {Store.StatePartSimple} */ +const SettingsDefault = +{ + Chan: { Min:0, Max:1, Value:0, Step:1 }, + Freq: { Min:2, Max:8, Value:3, Step:1 }, + Stim: { Min:-10, Max:120, Value:30, Step:5 }, + Errs: 0, + Pick: 0, + Show: { Cursor:true, Answer:false } +}; +/** @type {Store.StatePartSimple} */ +const SettingsActual = JSON.parse(localStorage.getItem("app-settings")||"false") || SettingsDefault; +/**@type {(inState:Store.State)=>void} */ +const SaveSettings =(inState)=> +{ + /** @type {Store.StatePartSimple} */ + const clone = { + Chan:inState.Chan, + Freq:inState.Freq, + Stim:inState.Stim, + Errs:inState.Errs, + Pick:inState.Pick, + Show:inState.Show + }; + localStorage.setItem("app-settings", JSON.stringify(clone)); +}; + +export const Initial = Reducer( +{ + ...SettingsActual, + Test: TestActual, + Live: + { + Test: undefined, + Freq: undefined, + Mark: undefined + }, + Draw: + { + UserL:{Points:[], Paths:[]}, + UserR:{Points:[], Paths:[]}, + TestL:{Points:[], Paths:[]}, + TestR:{Points:[], Paths:[]} + } +}, {Name:"Test", Data:SettingsActual.Pick}); + + +export const Context = React.createContext(/** @type {Store.Binding} */([Initial, (_a)=>{}])); + +/** @type {(props:{children:preact.ComponentChildren})=>preact.VNode} */ +export const Provider =(props)=> +{ + /** @type {Store.Binding} */ + const reducer = React.useReducer(Reducer, Initial); + return React.createElement(Context.Provider, {value:reducer, children:props.children}); +}; + +/** @type {()=>Store.Binding} */ +export const Consumer =()=> React.useContext(Context); \ No newline at end of file diff --git a/js/tone.js b/js/tone.js new file mode 100644 index 0000000..cf11e95 --- /dev/null +++ b/js/tone.js @@ -0,0 +1,65 @@ +// setup audio context +const AudioContextConstructor = window.AudioContext || window.webkitAudioContext; +const Context = new AudioContextConstructor(); + +// create audio nodes +const Oscillator = Context.createOscillator(); +const GainVolume = Context.createGain(); +const GainBeep = Context.createGain(); +const GainLeft = Context.createGain(); +const GainRight = Context.createGain(); +const ChannelMerge = Context.createChannelMerger(2); + +// wire up audio nodes +Oscillator.connect(GainVolume); +GainVolume.connect(GainBeep); +GainBeep.connect(GainLeft); +GainBeep.connect(GainRight); +GainLeft.connect(ChannelMerge, 0, 0); +GainRight.connect(ChannelMerge, 0, 1); +ChannelMerge.connect(Context.destination); + +// start +GainBeep.gain.value = 0; +GainLeft.gain.value = 0; +GainRight.gain.value = 0; +GainVolume.gain.value = 0; +Oscillator.start(Context.currentTime+0.0); + +const pad = 0.0015; + +/** @type {(inNode:AudioParam, inValue:number, inDelay:number)=>AudioParam} */ +const change = (inNode, inValue, inDelay) => inNode.linearRampToValueAtTime(inValue, Context.currentTime+inDelay); + +/** @type {(inNode:AudioParam, inStart:number, inDuration:number)=>void} */ +const pulse = (inNode, inStart, inDuration) => +{ + change(inNode, 0, inStart); + change(inNode, 1, inStart+pad); + change(inNode, 1, (inStart+inDuration)-pad ); + change(inNode, 0, (inStart+inDuration) ); +}; + +/** @type {(inContinuous:boolean, inChannel:number, inFreq:number, indBHL:number)=>void} */ +export const Play = (inContinuous, inChannel, inFreq, indBHL) => +{ + Context.resume(); + GainBeep.gain.cancelScheduledValues(Context.currentTime); + GainBeep.gain.setValueAtTime(0, Context.currentTime); + + change(GainLeft.gain, 1-inChannel, pad); + change(GainRight.gain, inChannel, pad); + change(Oscillator.frequency, inFreq, pad); + change(GainVolume.gain, indBHL, pad); + + if (inContinuous) + { + pulse(GainBeep.gain, 0.01, 0.8); + } + else + { + pulse(GainBeep.gain, 0.01, 0.2); + pulse(GainBeep.gain, 0.33, 0.2); + pulse(GainBeep.gain, 0.66, 0.2); + } +}; \ No newline at end of file diff --git a/js/twind.js b/js/twind.js new file mode 100644 index 0000000..adca149 --- /dev/null +++ b/js/twind.js @@ -0,0 +1,86 @@ +import * as TW from "@twind/core@1.0.1"; +import TWPreTail from "@twind/preset-tailwind@1.0.1"; +import TWPreAuto from "@twind/preset-autoprefix@1.0.1"; + +/** @type {TW.TwindUserConfig} */ +export const Configure = { + theme: + { + extend: + { + // @ts-ignore: typings for keyframes are missing in twind + keyframes: + { + flash: + { + '0%': { opacity: 1.0 }, + '50%': { opacity: 0.3 }, + '100%': { opacity: 0.0 } + }, + pulse: + { + "0%": { opacity: 0.0 }, + "10%": { opacity: 0.0 }, + "12%": { opacity: 1.0 }, + "22%": { opacity: 1.0 }, + "42%": { opacity: 0.2 }, + "100%": { opacity: 0.0 } + } + }, + animation: + { + flash: "flash 1s both", + pulse: "pulse 3s ease-in-out 0s 1 both" + }, + strokeWidth: + { + "bold": "4px" + } + } + }, + rules: + [ + [ + "stroke-draw", + { + "vector-effect": "non-scaling-stroke", + "stroke-linecap": "square", + "fill": "none" + }, + ], + [ + "bg-metal", + { + "background": "linear-gradient(159deg, rgb(228, 228, 228) 0%, rgb(243, 243, 243) 25%, rgb(236, 236, 236) 100%)" + }, + ], + [ + "bg-earmark", "bg-gradient-to-b from-[#107c79] to-[#115e67]" + ], + [ + 'shadow-glow-(.*)', + (match, context)=> + { + return { "box-shadow": `0px 0px 5px 2px ${context.theme().colors[match[1]]}` }; + } + ], + [ + 'shadow-sss', + { + "box-shadow": "rgb(0 0 0 / 50%) 0px -2px 3px inset, rgb(255 255 255 / 50%) 0px 10px 10px inset" + } + ], + [ + 'text-shadow-lcd', {"text-shadow": "0px 1px 1px #00000055"} + ], + [ 'box-notch', "border-t(1 [#ffffff]) border-r(1 [#ffffff]) border-b(1 [#00000033]) border-l(1 [#00000033]) flex items-center justify-end gap-1 p-2" ], + [ "box-buttons", "flex gap-1 items-center p-2 rounded-lg bg-gradient-to-b from-[#00000022] border-b(1 [#ffffff]) border-t(1 [#00000033])"] + ], + presets: [TWPreTail(), TWPreAuto()] +}; + +/** @type {(elStyle:HTMLStyleElement, elDiv:HTMLDivElement)=>void} */ +export const Init =(elStyle, elDiv)=> +{ + TW.observe(TW.twind(Configure, TW.cssom(elStyle)), elDiv); +}; \ No newline at end of file diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..19b36c1 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,404 @@ +import React from "react"; +import { html } from "htm"; +import * as Store from "./store.js"; +import * as Tone from "./tone.js"; + +/** @typedef {({children, classes}:{children?:preact.ComponentChildren, classes?:string})=>preact.VNode} BasicElement */ + +/** @type {({children, icon, light, disabled, inactive, onClick, classes}:{children:preact.VNode, icon?:preact.VNode, light:boolean, disabled:boolean, inactive:boolean, onClick:()=>void, classes?:string})=>preact.VNode} */ +export function Button({children, icon, light, disabled, inactive, onClick, classes}) +{ + const [FlashGet, FlashSet] = React.useState(0); + const handleClick =()=> + { + if(inactive||disabled){ return; } + FlashSet(FlashGet+1); + onClick(); + }; + + return html` + `; +} + +/** @type {BasicElement} */ +export const Header =()=> +{ + const [State, Dispatch] = Store.Consumer(); + const grade = State.Live.Test?.Done || {Marks:0, Total:0, Score:0}; + + /** @type {(e:Event)=>void} */ + const handleChange =(e)=> Dispatch({Name:"Test", Data:parseInt(/** @type {HTMLSelectElement}*/(e.target).value)}); + + return html` +
+ +
+ +
+ +
+
+ +
+
+

Patient Error:

+ <${Button} inactive=${State.Errs == 0.0} light=${State.Errs == 0.0} classes="flex-1 text-xs" onClick=${()=>Dispatch({Name:"Errs", Data:0.0})}>None + <${Button} inactive=${State.Errs == 0.1} light=${State.Errs == 0.1} classes="flex-1 text-xs" onClick=${()=>Dispatch({Name:"Errs", Data:0.1})}>Slight + <${Button} inactive=${State.Errs == 0.3} light=${State.Errs == 0.3} classes="flex-1 text-xs" onClick=${()=>Dispatch({Name:"Errs", Data:0.3})}>Moderate + <${Button} inactive=${State.Errs == 0.6} light=${State.Errs == 0.6} classes="flex-1 text-xs" onClick=${()=>Dispatch({Name:"Errs", Data:0.6})}>Severe +
+
+ +
+
+
Complete: ${grade.Marks} of ${grade.Total}
+
+
+
+
Accuracy: ${grade.Score}%
+ <${Button} disabled=${grade.Marks == 0} classes="flex-1 text-xs" onClick=${()=>Dispatch({Name:"Kill", Data:0})}>Start Over +
+
+
`; +} + +/** @type {BasicElement} */ +export const Display =()=> +{ + const [State, Dispatch] = Store.Consumer(); + return html` +
+
+
+ <${Button} light=${State.Show.Cursor} classes="flex-1 text-xs" onClick=${()=>Dispatch({Name:"ShowCursor", Data:!State.Show.Cursor})}>Cursor + <${Button} light=${State.Show.Answer} classes="flex-1 text-xs" onClick=${()=>Dispatch({Name:"ShowAnswer", Data:!State.Show.Answer})}>Answer +
+
+
+ `; +}; + +/** @type {BasicElement} */ +export const Controls =()=> +{ + const [State, Dispatch] = Store.Consumer(); + + const [pulsedGet, pulsedSet] = React.useState(true); + const [playGet, playSet] = React.useState(0); + React.useEffect(()=> + { + /** @type {number|undefined} */ + let timer; + if(playGet == 1) + { + const volNorm = (State.Stim.Value-State.Stim.Min)/(State.Stim.Max-State.Stim.Min); + Tone.Play(!pulsedGet, State.Chan.Value, Store.ColumnMapping[State.Freq.Value][0], (volNorm*0.8) + 0.1); + + if(State.Live.Freq) + { + const testMark = State.Live.Freq[/** @type {"TestL"|"TestR"}*/(`Test${State.Chan.Value ? "R":"L"}`)]; + const audible = State.Stim.Value >= testMark.Stim; + + const error = State.Stim.Value - testMark.Stim; + let errorMapped = error; + if(error >= 30){ errorMapped = 0.0; } + else if(error >= 25){ errorMapped = 0.1; } + else if(error >= 20){ errorMapped = 0.2; } + else if(error >= 15){ errorMapped = 0.3; } + else if(error >= 10){ errorMapped = 0.5; } + else if(error >= 5){ errorMapped = 0.7; } + else if(error == 0){ errorMapped = 1.0; } + else if(error >= -5){ errorMapped = 0.5; } + else if(error >=-10){ errorMapped = 0.1; } + else if(error >=-15){ errorMapped = 0.0; } + + const errorScaled = State.Errs*errorMapped; + const errorSampled = Math.random() < errorScaled; + const percieved = errorSampled ? !audible : audible + const handler = percieved ? ()=>playSet(2) : ()=>playSet(0); + console.log("Error:", error, "Error Mapped:", errorMapped, "Error Scaled:", errorScaled, "Error Sampled:", errorSampled); + timer = setTimeout(handler, 800 + Math.random()*1300); + } + } + return () => clearTimeout(timer); + + }, [playGet]); + + const classTitle = "flex-1 text-sm" + + return html` +
+
+
+
+ <${Button} inactive=${State.Chan.Value == 0} light=${State.Chan.Value == 0} classes="flex-1" onClick=${()=>Dispatch({Name:"Chan", Data:-1})}>Left + <${Button} inactive=${State.Chan.Value == 1} light=${State.Chan.Value == 1} classes="flex-1" onClick=${()=>Dispatch({Name:"Chan", Data:1})}>Right +
+
+
+
+
${Store.ColumnMapping[State.Freq.Value][0]} Hz
+ <${Button} disabled=${State.Freq.Value == State.Freq.Min} onClick=${()=>Dispatch({Name:"Freq", Data:-1})}> + + <${Glyph.Minus}/> + + + <${Button} disabled=${State.Freq.Value == State.Freq.Max} onClick=${()=>Dispatch({Name:"Freq", Data:1})}> + + <${Glyph.Plus}/> + + +
+
+
+
+
${State.Stim.Value} dbHL
+ <${Button} disabled=${State.Stim.Value == State.Stim.Min} onClick=${()=>Dispatch({Name:"Stim", Data:-1})}> + + <${Glyph.Minus}/> + + + <${Button} disabled=${State.Stim.Value == State.Stim.Max} onClick=${()=>Dispatch({Name:"Stim", Data:1})}> + + <${Glyph.Plus}/> + + +
+
+ +
+ +
+
+
+
+ <${Button} + classes="w-full flex-1 self-center" + onClick=${()=>playSet(1)} + disabled=${playGet==1} + icon=${html` + + `} + > + Present Tone + +
+ <${Button} onClick=${()=>{pulsedSet(true )}} light=${pulsedGet } inactive${pulsedGet } classes="flex-1 text(center xs)">Pulsed + <${Button} onClick=${()=>{pulsedSet(false)}} light=${!pulsedGet} inactive${!pulsedGet} classes="flex-1 text(center xs)">Continuous +
+
+
+ + + + + + + ${playGet == 2 && html``} + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ <${Button} + onClick=${()=>Dispatch({Name:"Mark", Data:true })} + classes="text-md w-full" + icon=${html` + + <${State.Chan.Value ? Glyph.O : Glyph.X}/> + `} + > + Accept + + <${Button} + onClick=${()=>Dispatch({Name:"Mark", Data:false})} + classes="text-sm w-full" + icon=${html` + + <${State.Chan.Value ? Glyph.O : Glyph.X}> + <${Glyph.Arrow}/> + + `} + > + No Response + + <${Button} + icon=${html` + + <${Glyph.Null}/> + + `} + onClick=${()=>Dispatch({Name:"Mark", Data:null })} + classes="text-sm w-full" + disabled=${State.Live.Mark == undefined} + > + Clear + +
+
+
+
+ `; +}; + +/** @type {BasicElement} */ +export const Audiogram =()=> +{ + const [State] = Store.Consumer(); + + const testMarksL = State.Draw.TestL.Points.map(p=>html`<${Mark} x=${p.X} y=${p.Y} response=${p.Mark?.Resp} right=${false}/>`); + const userMarksL = State.Draw.UserL.Points.map(p=>html`<${Mark} x=${p.X} y=${p.Y} response=${p.Mark?.Resp} right=${false} classes=${State.Live.Mark == p.Mark ? "stroke-bold":""}/>`); + const testMarksR = State.Draw.TestR.Points.map(p=>html`<${Mark} x=${p.X} y=${p.Y} response=${p.Mark?.Resp} right=${true} />`); + const userMarksR = State.Draw.UserR.Points.map(p=>html`<${Mark} x=${p.X} y=${p.Y} response=${p.Mark?.Resp} right=${true} classes=${State.Live.Mark == p.Mark ? "stroke-bold":""}/>`); + + const testLinesL = State.Draw.TestL.Paths.map( p=>html``); + const userLinesL = State.Draw.UserL.Paths.map( p=>html``); + const testLinesR = State.Draw.TestR.Paths.map( p=>html``); + const userLinesR = State.Draw.UserR.Paths.map( p=>html``); + + return html` + ${ + State.Show.Answer && html` + ${testMarksL}${testLinesL} + ${testMarksR}${testLinesR} + ` + } + ${userMarksL}${userLinesL} + ${userMarksR}${userLinesR} + ${ + State.Show.Cursor && html` + + + + + + + + + + + ` + }`; +}; + + +/** @type {BasicElement} */ +export function Chart({children}) +{ + const [State] = Store.Consumer(); + const inset = 20; + /** @type {Array} */ + const rules = []; + Store.ColumnMapping.forEach(([label, position, normal])=> + { + rules.push(html` + + ${label} + ` + ); + }); + + for(let db = State.Stim.Min; db <= State.Stim.Max; db+=10) + { + rules.push(html` + ` + ); + } + return html` +
+
+ Frequency in Hz + + + Hearing Level (dbHL) + + +
+ + ${ rules } +
+ ${ children } +
+
+
+
`; +} + +/** @type {Record} */ +export const Glyph = { + Arrow:()=> html` + + + `, + + X: ({children})=> html` + + + ${children}`, + + O: ({children})=> html` + + ${children}`, + + Minus:()=>html` `, + Plus:()=>html` + `, + + Null:()=>html` + + + ` + +}; + +/** @type {({right, response, x, y, classes}:{right:boolean, response?:boolean, x:number|string, y:number|string, classes:string})=>preact.VNode} */ +export const Mark =({right, response, x, y, classes})=> +{ + return html` + + + <${ right ? Glyph.O : Glyph.X }> + ${ !response && html`<${Glyph.Arrow}/>` } + + + `; +}; \ No newline at end of file diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..0c43e16 Binary files /dev/null and b/logo.png differ diff --git a/src/app.js b/src/app.js deleted file mode 100644 index be31cc0..0000000 --- a/src/app.js +++ /dev/null @@ -1,114 +0,0 @@ -//@ts-check -import * as TW from "https://esm.sh/@twind/core@1.0.1"; -import TWPreTail from "https://esm.sh/@twind/preset-tailwind@1.0.1"; -import TWPreAuto from "https://esm.sh/@twind/preset-autoprefix@1.0.1"; - -import * as UI from "./ui.js"; -import { Reducer, Initial } from "./store.js"; -import React from "https://esm.sh/preact@10.11.3/compat"; -import {html} from "https://esm.sh/htm@3.1.1/preact"; - - -/** @type {TW.TwindConfig} */ -const Configure = { - theme: - { - extend: - { - keyframes: - { - flash: - { - '0%': { opacity: 1.0 }, - '50%': { opacity: 0.3 }, - '100%': { opacity: 0.0 } - }, - pulse: - { - "0%": { opacity: 0.0 }, - "10%": { opacity: 0.0 }, - "12%": { opacity: 1.0 }, - "22%": { opacity: 1.0 }, - "42%": { opacity: 0.2 }, - "100%": { opacity: 0.0 } - } - }, - animation: - { - flash: "flash 1s both" - }, - strokeWidth: - { - "bold": "3px" - } - } - }, - rules: - [ - [ - "stroke-draw", - { - "vector-effect": "non-scaling-stroke", - "stroke-linecap": "square", - "fill": "none" - }, - ], - [ - 'shadow-glow-(.*)', - (match, context)=> - { - return { "box-shadow": `0px 0px 5px 2px ${context.theme().colors[match[1]]}` }; - } - ], - [ - 'shadow-sss', - { - "box-shadow": "rgb(0 0 0 / 50%) 0px -3px 2px inset, rgb(255 255 255 / 50%) 0px 10px 10px inset" - } - ] - ], - presets: [TWPreTail(), TWPreAuto()] -}; -const ShadowDOM = document.querySelector("#app").attachShadow({mode: "open"}); -const ShadowDiv = document.createElement("div"); -const ShadowCSS = document.createElement("style"); -ShadowDOM.append(ShadowCSS); -ShadowDOM.append(ShadowDiv); -TW.observe(TW.twind(Configure, TW.cssom(ShadowCSS)), ShadowDiv); - - -const StoreContext = React.createContext(null); -const StoreProvider =(props)=> -{ - const reducer = React.useReducer(Reducer, Initial); - return html`<${StoreContext.Provider} value=${reducer}>${props.children}`; -} -const StoreConsumer =()=> React.useContext(StoreContext); - - -const Deep =()=> -{ - const [State, Dispatch] = React.useContext(StoreContext); - return html` - <${UI.Button} onClick=${()=>Dispatch({Name:"Stim", Data:1})} disabled=${State.Stim.Value == State.Stim.Max}> - ${State.Stim.Value} - `; -} - -React.render(html` - <${StoreProvider}> - <${UI.Button} icon="+">hey! - <${UI.Button} light>Left - <${UI.Button} inactive>Right - <${UI.Button} disabled>Right - <${Deep}/> - <${UI.Chart}> - - <${UI.Mark} right=${true} x=${"20%"} y="20%" /> - <${UI.Mark} right=${false} x=${"10%"} y="20%" response=${true} /> - <${UI.Mark} right=${false}/> - - - - -`, ShadowDiv); \ No newline at end of file diff --git a/src/store.js b/src/store.js deleted file mode 100644 index 96113fd..0000000 --- a/src/store.js +++ /dev/null @@ -1,237 +0,0 @@ -//@ts-check - -const size = 1/6; -/** @typedef {[frequency:number, position:number, normal:boolean]} ColumnMapping */ -/** @type {Array} */ -export const ColumnMapping = [ - [ 125, size*0.0, true ], - [ 250, size*1.0, true ], - [ 500, size*2.0, true ], - [1000, size*3.0, true ], - [2000, size*4.0, true ], - [3000, size*4.5, false], - [4000, size*5.0, true ], - [6000, size*5.5, false], - [8000, size*6.0, true ] -]; -/** @type {(inFrequency:number)=>ColumnMapping|false} */ -export const ColumnLookup =(inFrequency)=> -{ - for(let i=0; iTestFrequencySample|undefined} */ -export const MarkGet =(freq, chan, user)=> freq[/** @type {"UserL"|"UserR"|"TestL"|"TestR"} */ (`${user ? "User" : "Test"}${chan ? "R" : "L"}`)]; - -/** @type {(freq:TestFrequency, chan:number, mark:TestFrequencySample|undefined)=>TestFrequencySample|undefined} */ -export const MarkSet =(freq, chan, mark)=> freq[ chan ? "UserR" : "UserL" ] = mark; - -/** @typedef {{Min:number, Max:number, Value:number, Step:number}} Range */ -/** @typedef {{Stim:number, Resp:boolean}} TestFrequencySample */ -/** @typedef {{Hz:number, TestL:TestFrequencySample, TestR:TestFrequencySample, UserL?:TestFrequencySample, UserR?:TestFrequencySample}} TestFrequency */ -/** @typedef {{Name:string, Plot:Array}} Test */ -/** @typedef {{Test?:Test, Freq?:TestFrequency, Mark?:TestFrequencySample}} Context */ -/** @typedef {{Chan:Range, Freq:Range, Stim:Range, Live:Context, Draw:{UserL:DrawGroup, UserR:DrawGroup, TestL:DrawGroup, TestR:DrawGroup}, Tests:Array}} State */ -/** @type {State} */ -export const Initial = -{ - Chan: { Min:0, Max:1, Value:0, Step:1 }, - Freq: { Min:2, Max:8, Value:2, Step:1 }, - Stim: { Min:-10, Max:120, Value:30, Step:5 }, - Live: - { - Test: undefined, - Freq: undefined, - Mark: undefined - }, - Draw: - { - UserL:{Points:[], Paths:[]}, - UserR:{Points:[], Paths:[]}, - TestL:{Points:[], Paths:[]}, - TestR:{Points:[], Paths:[]} - }, - Tests: [ - { - Name: "Patient A Asymmetric Notch", - Plot: - [ - { Hz: 500, TestL: { Stim: 30, Resp: true }, TestR: { Stim: 50, Resp: true } }, - { Hz: 1000, TestL: { Stim: 50, Resp: true }, TestR: { Stim: 55, Resp: true } } - ] - } - ] -}; - -/** @typedef {{Name:"Mark", Data:boolean|null}} ActionMark */ -/** @typedef {{Name:"Test", Data:number}} ActionTest */ -/** @typedef {{Name:"Chan", Data:number}} ActionChan */ -/** @typedef {{Name:"Freq", Data:number}} ActionFreq */ -/** @typedef {{Name:"Stim", Data:number}} ActionStim */ -/** @typedef {ActionMark|ActionTest|ActionChan|ActionFreq|ActionStim} Action */ -/** @typedef {(inState:State, inAction:Action)=>State} Reducer */ -/** @typedef {(inState:State)=>boolean} SelectionUpdater */ -/** @type {Record} */ -const Update = -{ - Freq(inState) - { - const column = ColumnMapping[inState.Freq.Value]; - if(column && inState.Live.Test) - { - const hz = column[0]; - inState.Live.Freq = undefined; - for(let i=0; i, Paths:Array>}} DrawGroup */ -/** @typedef {{Left:DrawGroup, Right:DrawGroup}} DrawChart */ -/** @typedef {{User?:DrawChart, Test?:DrawChart}} DrawTest */ -/** @type {(inTest:Test, inChan:number, inStim:Range, inIsUser:boolean)=>DrawGroup} */ -export function Congtiguous(inTest, inChan, inStim, inIsUser) -{ - /** @type {DrawGroup} */ - const output = {Points:[], Paths:[]}; - - let plot; - let valid = false; - /** @type {Array} */ - let segment = []; - for(let i=0; i tone.Max){ tone.Value = tone.Max; } - clone[Name] = tone; - if(Name != "Stim") - { - Update.Freq(clone); - Update.Mark(clone); - } - } - - return clone; -} - -/* -const minified = -[ - 1, - [ - [20, 30, 50, 40, 60, 80],[20, 30, 50, 40, 60, 80] - ] -]; -const Expand =(inMin)=> -{ - const outTests = []; - const inFreq = inMin[0]; - for(let i=1; iJSX.Element} BasicElement */ - -/** @type {({children, icon, light, disabled, inactive, onClick}:{children:React.ReactNode, icon?:JSX.Element, light:boolean, disabled:boolean, inactive:boolean, onClick:()=>void})=>JSX.Element} */ -export function Button({children, icon, light, disabled, inactive, onClick}) -{ - const [FlashGet, FlashSet] = React.useState(0); - const handleClick =()=> - { - if(inactive||disabled){ return; } - FlashSet(FlashGet+1); - onClick(); - }; - - return html` - `; -} - -/** @type {BasicElement} */ -export function Chart({children}) -{ - const inset = 20; - /** @type {Array} */ - const rules = []; - ColumnMapping.forEach(([label, position, normal])=> - { - rules.push(html` - - ${label} - `); - }); - - const dbMin = -10; - const dbMax = 120; - for(let db = dbMin; db <= dbMax; db+=10) - { - rules.push(html` - - `); - } - return html` -
-
- Frequency in Hz - - - Hearing Level (dbHL) - - -
- - ${ rules } -
- ${ children } -
-
-
-
- `; -} - - -/** @type {Record} */ -const Glyph = { - Arrow:({children})=> html` - - - `, - - //style="transform: translate(50%, 50%) rotate(-15deg) scale(0.5);" - X: ({children})=> html` - - - ${children}`, - - O: ({children})=> html` - - ${children}` -}; - -/** @type {({right, response, x, y}:{right:boolean, response?:boolean, x:string|number, y:string|number})=>JSX.Element} */ -export function Mark({right, response, x, y}) -{ - return html` - - <${ right ? Glyph.O : Glyph.X }> - ${ !response && html`<${Glyph.Arrow}/>` } - - - `; -} \ No newline at end of file diff --git a/ts/store.d.ts b/ts/store.d.ts new file mode 100644 index 0000000..cdde39f --- /dev/null +++ b/ts/store.d.ts @@ -0,0 +1,78 @@ +declare namespace Store { + type ColumnMapping = [frequency: number, position: number, normal: boolean]; + + type Range = { Min: number; Max: number; Value: number; Step: number }; + type TestFrequencySample = { Stim: number; Resp: boolean }; + + type TestFrequency = { + Hz: number; + TestL: TestFrequencySample; + TestR: TestFrequencySample; + UserL?: TestFrequencySample; + UserR?: TestFrequencySample; + }; + + type Test = { + Name : string; + Done?: Grade; + Plot : Array + }; + + type Context = { + Test?: Test; + Freq?: TestFrequency; + Mark?: TestFrequencySample; + }; + + type StatePartSimple = + { + Chan: Range; + Freq: Range; + Stim: Range; + Errs: number; + Pick: number; + Show: + { + Cursor:boolean, + Answer:boolean + } + }; + type StatePartComplex = + { + Live: Context; + Draw: DrawChart; + Test: Array; + }; + + type State = StatePartSimple & StatePartComplex; + + type ActionMark = { Name: "Mark"; Data: boolean | null }; + type ActionTest = { Name: "Test"; Data: number }; + type ActionChan = { Name: "Chan"; Data: number }; + type ActionFreq = { Name: "Freq"; Data: number }; + type ActionStim = { Name: "Stim"; Data: number }; + type ActionErrs = { Name: "Errs"; Data: number }; + type ActionKill = { Name: "Kill"; Data: number }; + type ActionShowCursor = {Name: "ShowCursor", Data:boolean}; + type ActionShowAnswer = {Name: "ShowAnswer", Data:boolean}; + type Action = ActionMark | ActionTest | ActionChan | ActionFreq | ActionStim | ActionErrs | ActionKill | ActionShowCursor | ActionShowAnswer; + type Reducer = (inState: State, inAction: Action) => State; + type ContextUpdater = (inState: State) => boolean; + + type PlotKeyUser = "UserL" | "UserR"; + type PlotKeyTest = "TestL" | "TestR"; + type PlotKey = PlotKeyUser | PlotKeyTest; + + type DrawPoint = { X: string; Y: string; Mark?: TestFrequencySample }; + type DrawLine = { Head:DrawPoint, Tail:DrawPoint}; + type DrawGroup = { Points: Array; Paths: Array }; + type DrawChart = { Cross?:DrawPoint, UserL: DrawGroup, UserR: DrawGroup, TestL: DrawGroup, TestR: DrawGroup }; + + type Binding = [state:State, dispatch:(inAction:Action)=>void] + + type Grade = { + Total:number, + Marks:number, + Score:number + }; +} \ No newline at end of file diff --git a/test/store_test.js b/ts/store.test.ts similarity index 50% rename from test/store_test.js rename to ts/store.test.ts index 1fd7d1e..7c29953 100644 --- a/test/store_test.js +++ b/ts/store.test.ts @@ -1,7 +1,34 @@ import { assertEquals } from "https://deno.land/std@0.166.0/testing/asserts.ts"; -import { Reducer, ColumnMapping, Congtiguous, Initial } from "../src/store.js"; +import { Reducer, ColumnMapping, Initial } from "../js/store.js"; -let state = {...Initial}; +let state:Store.State = { + Chan: { Min:0, Max:1, Value:0, Step:1 }, + Freq: { Min:2, Max:8, Value:2, Step:1 }, + Stim: { Min:-10, Max:120, Value:30, Step:5 }, + Live: + { + Test: undefined, + Freq: undefined, + Mark: undefined + }, + Draw: + { + UserL:{Points:[], Paths:[]}, + UserR:{Points:[], Paths:[]}, + TestL:{Points:[], Paths:[]}, + TestR:{Points:[], Paths:[]} + }, + Test: [ + { + Name: "Patient A Asymmetric Notch", + Plot: + [ + { Hz: 500, TestL: { Stim: 30, Resp: true }, TestR: { Stim: 50, Resp: true }, UserL: { Stim: 55, Resp: true }, UserR: { Stim: 50, Resp: true } }, + { Hz: 1000, TestL: { Stim: 50, Resp: true }, TestR: { Stim: 55, Resp: true }, UserL: { Stim: 50, Resp: true }, UserR: { Stim: 30, Resp: true } } + ] + } + ] +}; Deno.test("Initialize", async(t)=> { @@ -15,8 +42,8 @@ Deno.test("Initialize", async(t)=> await t.step("A test exists with 500 and 1k hz plots", ()=> { - assertEquals(state.Tests.length > 0, true); - const test = state.Tests[0]; + assertEquals(state.Test.length > 0, true); + const test = state.Test[0]; assertEquals(test.Plot.length > 1, true); assertEquals(test.Plot[0].Hz, 500); assertEquals(test.Plot[1].Hz, 1000); @@ -40,8 +67,8 @@ Deno.test("Initialize", async(t)=> await t.step("Live context values are correct", ()=> { - assertEquals(state.Live.Test, state.Tests[0]); - assertEquals(state.Live.Freq.Hz, ColumnMapping[state.Freq.Value][0]); + assertEquals(state.Live.Test, state.Test[0]); + assertEquals(state.Live.Freq?.Hz, ColumnMapping[state.Freq.Value][0]); assertEquals(state.Live.Mark, undefined, "(User) Mark is undefined"); }); }); @@ -64,10 +91,10 @@ Deno.test("Make Marks", async(t)=> await t.step("Check marked value", ()=> { - assertEquals(state.Live.Freq.UserL !== undefined, true, `there will be a user mark for the left channel`); - assertEquals(state.Live.Freq.UserR === undefined, true, `but not the right`); - assertEquals(state.Live.Mark.Stim, state.Stim.Value); - assertEquals(state.Live.Mark.Resp, true); + assertEquals(state.Live.Freq?.UserL !== undefined, true, `there will be a user mark for the left channel`); + assertEquals(state.Live.Freq?.UserR === undefined, true, `but not the right`); + assertEquals(state.Live.Mark?.Stim, state.Stim.Value); + assertEquals(state.Live.Mark?.Resp, true); }); await t.step("Dispatch Freq, Stim, and Chan updates", ()=> @@ -85,40 +112,24 @@ Deno.test("Make Marks", async(t)=> await t.step("Check marked value", ()=> { - assertEquals(state.Live.Freq.UserR !== undefined, true, `there will be a user mark for the right channel`); - assertEquals(state.Live.Freq.UserL !== undefined, true, `and the left`); - assertEquals(state.Live.Mark.Stim, state.Stim.Value); - assertEquals(state.Live.Mark.Resp, false); + assertEquals(state.Live.Freq?.UserR !== undefined, true, `there will be a user mark for the right channel`); + assertEquals(state.Live.Freq?.UserL !== undefined, true, `and the left`); + assertEquals(state.Live.Mark?.Stim, state.Stim.Value); + assertEquals(state.Live.Mark?.Resp, false); }); await t.step("Live context values are correct", ()=> { - assertEquals(state.Live.Test, state.Tests[0]); - assertEquals(state.Live.Freq.Hz, ColumnMapping[state.Freq.Value][0]); - assertEquals(state.Live.Mark.Stim, state.Stim.Value); + assertEquals(state.Live.Test, state.Test[0]); + assertEquals(state.Live.Freq?.Hz, ColumnMapping[state.Freq.Value][0]); + assertEquals(state.Live.Mark?.Stim, state.Stim.Value); + }); + + await t.step("Check Draw output", ()=> + { + assertEquals(state.Draw.TestL.Points.length, 2); + assertEquals(state.Draw.TestL.Paths.length, 1); }); console.log(state.Draw); -}); - -Deno.test("Contiguous Lines", ()=> -{ - /** @type {import("../src/store.js").Test} */ - const model = { - Name:"", - Plot:[ - {Hz: 500, TestL: {Stim:30, Resp:true}, TestR: {Stim:35, Resp:true}, UserL:{Stim:20, Resp:true}}, - {Hz: 1000, TestL: {Stim:40, Resp:true}, TestR: {Stim:45, Resp:true}, UserL:{Stim:30, Resp:true}}, - {Hz: 2000, TestL: {Stim:40, Resp:true}, TestR: {Stim:45, Resp:true}, UserL:{Stim:30, Resp:false}}, - {Hz: 3000, TestL: {Stim:30, Resp:true}, TestR: {Stim:35, Resp:true}, UserL:{Stim:20, Resp:true}}, - {Hz: 4000, TestL: {Stim:40, Resp:true}, TestR: {Stim:45, Resp:true}, UserL:{Stim:30, Resp:true}}, - {Hz: 4000, TestL: {Stim:50, Resp:true}, TestR: {Stim:55, Resp:true}, UserL:{Stim:40, Resp:true}} - ] - } - - const {Points, Paths} = Congtiguous(model, 0, Initial.Stim, true); - assertEquals(Points.length, 6); - assertEquals(Paths.length, 2); - assertEquals(Paths[0].length, 2); - assertEquals(Paths[1].length, 3); }); \ No newline at end of file