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`
+ `;
+}
+
+/** @type {BasicElement} */
+export const Display =()=>
+{
+ const [State, Dispatch] = Store.Consumer();
+ return html`
+
+ `;
+};
+
+/** @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`
+
+ `;
+};
+
+/** @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`
+
+
+ `
+ }
+
+
+ ${
+ 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`
+
+ ${db}
+ `
+ );
+ }
+ 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`
+ `;
+};
\ 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}>
-
- />
- />
-`, 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`
-
- ${db}
-
- `);
- }
- 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`
-
- `;
-}
\ 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