Skip to content

Commit 859971c

Browse files
authored
Merge pull request #2854 from Brain-up/feat/adaptive-audiometry-threshold
Add adaptive threshold audiometry with audiogram visualization
2 parents 8e10fbe + 7bd5e4f commit 859971c

12 files changed

Lines changed: 1733 additions & 107 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
.audiogram-container {
2+
max-width: 600px;
3+
margin: 0 auto 1.5rem;
4+
}
5+
6+
.audiogram-chart {
7+
min-height: 320px;
8+
}
9+
10+
/* Billboard.js region overrides for hearing level bands */
11+
:global(.audiogram-region-normal) rect {
12+
fill: #dcfce7 !important;
13+
opacity: 0.5;
14+
}
15+
16+
:global(.audiogram-region-mild) rect {
17+
fill: #fef9c3 !important;
18+
opacity: 0.5;
19+
}
20+
21+
:global(.audiogram-region-moderate) rect {
22+
fill: #fed7aa !important;
23+
opacity: 0.5;
24+
}
25+
26+
:global(.audiogram-region-mod-severe) rect {
27+
fill: #fecaca !important;
28+
opacity: 0.5;
29+
}
30+
31+
:global(.audiogram-region-severe) rect {
32+
fill: #fca5a5 !important;
33+
opacity: 0.5;
34+
}
35+
36+
:global(.audiogram-threshold-line) line {
37+
stroke: #059669;
38+
stroke-dasharray: 4;
39+
}
40+
41+
:global(.audiogram-threshold-line) text {
42+
fill: #059669;
43+
font-size: 11px;
44+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import 'billboard.js/dist/billboard.min.css';
2+
import Component from '@glimmer/component';
3+
import { action } from '@ember/object';
4+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
5+
import { service } from '@ember/service';
6+
import type { Chart, ChartOptions } from 'billboard.js';
7+
import willDestroy from '@ember/render-modifiers/modifiers/will-destroy';
8+
import didInsert from '@ember/render-modifiers/modifiers/did-insert';
9+
import didUpdate from '@ember/render-modifiers/modifiers/did-update';
10+
import { t } from 'ember-intl';
11+
import type IntlService from 'ember-intl/services/intl';
12+
13+
const STANDARD_FREQUENCIES = [125, 250, 500, 1000, 2000, 4000, 8000];
14+
const FREQ_LABELS = ['125', '250', '500', '1K', '2K', '4K', '8K'];
15+
16+
interface AudiogramSignature {
17+
Args: {
18+
leftEarThresholds: Record<number, number>;
19+
rightEarThresholds: Record<number, number>;
20+
};
21+
Element: HTMLElement;
22+
}
23+
24+
export default class AudiogramComponent extends Component<AudiogramSignature> {
25+
@service('intl') intl!: IntlService;
26+
27+
private chart: Chart | undefined;
28+
private chartId = `audiogram-${Math.random().toString(36).slice(2, 8)}`;
29+
30+
get chartElemRef(): HTMLDivElement | null {
31+
return document.getElementById(this.chartId) as HTMLDivElement | null;
32+
}
33+
34+
private get leftLabel(): string {
35+
return `${this.intl.t('audiometry.ear_left')} (X)`;
36+
}
37+
38+
private get rightLabel(): string {
39+
return `${this.intl.t('audiometry.ear_right')} (O)`;
40+
}
41+
42+
private buildColumns(): [string, ...(number | null)[]][] {
43+
const leftData: (number | null)[] = STANDARD_FREQUENCIES.map(
44+
(f) => this.args.leftEarThresholds[f] ?? null,
45+
);
46+
const rightData: (number | null)[] = STANDARD_FREQUENCIES.map(
47+
(f) => this.args.rightEarThresholds[f] ?? null,
48+
);
49+
return [
50+
[this.leftLabel, ...leftData],
51+
[this.rightLabel, ...rightData],
52+
];
53+
}
54+
55+
@action
56+
async buildChart() {
57+
const el = this.chartElemRef;
58+
if (!el) return;
59+
60+
const { line, bb } = await import('billboard.js');
61+
62+
// Guard against component being destroyed during async import
63+
if (this.isDestroyed || this.isDestroying) return;
64+
65+
// Eagerly capture all i18n strings before passing to billboard.js
66+
// to avoid accessing this.intl in callbacks after component destruction
67+
const leftLabel = this.leftLabel;
68+
const rightLabel = this.rightLabel;
69+
const freqAxisLabel = this.intl.t('audiometry.audiogram_freq_axis');
70+
const dbAxisLabel = this.intl.t('audiometry.audiogram_db_axis');
71+
const normalLabel = this.intl.t('audiometry.hearing_normal');
72+
73+
const columns = this.buildColumns();
74+
75+
const options: ChartOptions = {
76+
bindto: el,
77+
data: {
78+
type: line(),
79+
columns,
80+
colors: {
81+
[leftLabel]: '#2563EB',
82+
[rightLabel]: '#DC2626',
83+
},
84+
// connectNull is supported by billboard.js but missing from the type definitions
85+
connectNull: true,
86+
} as ChartOptions['data'],
87+
axis: {
88+
x: {
89+
type: 'category' as const,
90+
categories: FREQ_LABELS,
91+
label: {
92+
text: freqAxisLabel,
93+
position: 'outer-center',
94+
},
95+
},
96+
y: {
97+
inverted: true,
98+
min: -10,
99+
max: 100,
100+
label: {
101+
text: dbAxisLabel,
102+
position: 'outer-middle',
103+
},
104+
tick: { values: [-10, 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] },
105+
},
106+
},
107+
grid: {
108+
y: {
109+
show: true,
110+
lines: [
111+
{
112+
value: 20,
113+
text: normalLabel,
114+
class: 'audiogram-threshold-line',
115+
},
116+
],
117+
},
118+
x: { show: true },
119+
},
120+
point: { r: 5 },
121+
legend: { show: true },
122+
tooltip: {
123+
format: {
124+
value: (value: number) => `${value} ${dbAxisLabel}`,
125+
},
126+
},
127+
regions: [
128+
{ axis: 'y', start: -10, end: 20, class: 'audiogram-region-normal' },
129+
{ axis: 'y', start: 20, end: 40, class: 'audiogram-region-mild' },
130+
{ axis: 'y', start: 40, end: 55, class: 'audiogram-region-moderate' },
131+
{ axis: 'y', start: 55, end: 70, class: 'audiogram-region-mod-severe' },
132+
{ axis: 'y', start: 70, end: 100, class: 'audiogram-region-severe' },
133+
],
134+
};
135+
136+
this.chart = bb.generate(options);
137+
}
138+
139+
@action
140+
updateChart() {
141+
if (!this.chart) return;
142+
const columns = this.buildColumns();
143+
this.chart.load({ columns });
144+
}
145+
146+
@action
147+
onWillDestroy() {
148+
this.chart?.destroy();
149+
}
150+
151+
<template>
152+
<div class="audiogram-container" data-test-audiogram>
153+
<div
154+
id={{this.chartId}}
155+
class="audiogram-chart"
156+
...attributes
157+
{{willDestroy this.onWillDestroy}}
158+
{{didInsert this.buildChart}}
159+
{{didUpdate this.updateChart @leftEarThresholds @rightEarThresholds}}
160+
></div>
161+
<div class="flex flex-wrap justify-center gap-3 mt-3 text-xs text-gray-500">
162+
<span class="inline-flex items-center gap-1">
163+
<span class="inline-block w-3 h-3 rounded-sm bg-green-100 border border-green-300"></span>
164+
{{t "audiometry.audiogram_normal"}}
165+
</span>
166+
<span class="inline-flex items-center gap-1">
167+
<span class="inline-block w-3 h-3 rounded-sm bg-yellow-100 border border-yellow-300"></span>
168+
{{t "audiometry.audiogram_mild"}}
169+
</span>
170+
<span class="inline-flex items-center gap-1">
171+
<span class="inline-block w-3 h-3 rounded-sm bg-orange-100 border border-orange-300"></span>
172+
{{t "audiometry.audiogram_moderate"}}
173+
</span>
174+
<span class="inline-flex items-center gap-1">
175+
<span class="inline-block w-3 h-3 rounded-sm bg-red-100 border border-red-300"></span>
176+
{{t "audiometry.audiogram_severe"}}
177+
</span>
178+
</div>
179+
</div>
180+
</template>
181+
}

0 commit comments

Comments
 (0)