diff --git a/packages/keybr-chart/lib/TimeToTypeHistogram.tsx b/packages/keybr-chart/lib/TimeToTypeHistogram.tsx
new file mode 100644
index 00000000..fdabc6a7
--- /dev/null
+++ b/packages/keybr-chart/lib/TimeToTypeHistogram.tsx
@@ -0,0 +1,87 @@
+import { useFormatter } from "@keybr/lesson-ui";
+import { Range, Vector } from "@keybr/math";
+import { timeToSpeed } from "@keybr/result";
+import { Canvas, type Rect, type ShapeList, Shapes } from "@keybr/widget";
+import { type ReactNode } from "react";
+import { Chart, chartArea, type SizeProps } from "./Chart.tsx";
+import { withStyles } from "./decoration.ts";
+import { bucketize } from "./dist/util.ts";
+import { type TimeToType } from "./types.ts";
+import { type ChartStyles, useChartStyles } from "./use-chart-styles.ts";
+
+export function TimeToTypeHistogram({
+ steps,
+ width,
+ height,
+}: {
+ readonly steps: readonly TimeToType[];
+} & SizeProps): ReactNode {
+ const styles = useChartStyles();
+ const paint = usePaint(styles, steps);
+ return (
+
+
+
+ );
+}
+
+function usePaint(styles: ChartStyles, steps: readonly TimeToType[]) {
+ const { formatSpeed } = useFormatter();
+ const g = withStyles(styles);
+
+ const histogram = buildHistogram(steps);
+
+ const vIndex = new Vector();
+ const vValue = new Vector();
+ for (let index = 0; index < histogram.length; index++) {
+ vIndex.add(index);
+ vValue.add(histogram[index]);
+ }
+ const rIndex = Range.from(vIndex);
+ const rValue = Range.from(vValue);
+
+ return (box: Rect): ShapeList => {
+ return [
+ g.paintGrid(box, "vertical", { lines: 5 }),
+ g.paintGrid(box, "horizontal", { lines: 5 }),
+ paintHistogram(),
+ g.paintAxis(box, "bottom"),
+ g.paintAxis(box, "left"),
+ g.paintTicks(box, rIndex, "bottom", {
+ lines: 5,
+ fmt: formatSpeed,
+ style: styles.valueLabel,
+ }),
+ ];
+
+ function paintHistogram(): ShapeList {
+ return Shapes.fill(
+ styles.speed,
+ [...rIndex.steps()].map((index) => {
+ const w = Math.ceil(box.width / rIndex.span);
+ const x = Math.round(rIndex.normalize(index, 1) * box.width);
+ const y = Math.round(rValue.normalize(vValue.at(index)) * box.height);
+ return Shapes.rect({
+ x: box.x + x,
+ y: box.y + box.height - y,
+ width: w,
+ height: y,
+ });
+ }),
+ );
+ }
+ };
+}
+
+function buildHistogram(steps: readonly TimeToType[]) {
+ const histogram = new Array(1501).fill(0);
+ for (const { timeToType } of steps) {
+ if (timeToType > 0) {
+ const index = Math.round(timeToSpeed(timeToType));
+ if (index >= 0 && index < histogram.length) {
+ histogram[index] += 1;
+ }
+ }
+ }
+ return bucketize(histogram, 30);
+}
diff --git a/packages/keybr-chart/lib/index.ts b/packages/keybr-chart/lib/index.ts
index a9b82d69..cbc05b4d 100644
--- a/packages/keybr-chart/lib/index.ts
+++ b/packages/keybr-chart/lib/index.ts
@@ -12,4 +12,5 @@ export * from "./Marker.tsx";
export * from "./ProgressOverviewChart.tsx";
export * from "./SpeedChart.tsx";
export * from "./SpeedHistogram.tsx";
+export * from "./TimeToTypeHistogram.tsx";
export * from "./types.ts";
diff --git a/packages/keybr-chart/lib/types.ts b/packages/keybr-chart/lib/types.ts
index e75cd07c..ada411f4 100644
--- a/packages/keybr-chart/lib/types.ts
+++ b/packages/keybr-chart/lib/types.ts
@@ -2,3 +2,5 @@ export type Threshold = {
readonly label: string;
readonly value: number;
};
+
+export type TimeToType = { readonly timeToType: number };
diff --git a/packages/page-typing-test/lib/components/Report.tsx b/packages/page-typing-test/lib/components/Report.tsx
index 6d37a86d..de918b68 100644
--- a/packages/page-typing-test/lib/components/Report.tsx
+++ b/packages/page-typing-test/lib/components/Report.tsx
@@ -3,6 +3,7 @@ import {
makeAccuracyDistribution,
makeSpeedDistribution,
SpeedHistogram,
+ TimeToTypeHistogram,
} from "@keybr/chart";
import { useIntlNumbers } from "@keybr/intl";
import { useFormatter } from "@keybr/lesson-ui";
@@ -118,6 +119,16 @@ export const Report = memo(function Report({
+
+
+
+
+ Time to type a character histogram.
+