@bezda/rhp-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/bin/rhp-mcp.js +125 -0
- package/package.json +62 -4
- package/skill/SKILL.md +363 -0
- package/skill/recipes/area.html +150 -0
- package/skill/recipes/bar.html +139 -0
- package/skill/recipes/box-plot.html +158 -0
- package/skill/recipes/bubble.html +182 -0
- package/skill/recipes/bullet.html +132 -0
- package/skill/recipes/candlestick.html +173 -0
- package/skill/recipes/column.html +125 -0
- package/skill/recipes/diverging-bars.html +144 -0
- package/skill/recipes/donut.html +225 -0
- package/skill/recipes/dumbbell.html +131 -0
- package/skill/recipes/gantt.html +186 -0
- package/skill/recipes/grouped-bars.html +136 -0
- package/skill/recipes/heatmap.html +177 -0
- package/skill/recipes/histogram.html +193 -0
- package/skill/recipes/line.html +164 -0
- package/skill/recipes/live.html +249 -0
- package/skill/recipes/lollipop.html +146 -0
- package/skill/recipes/multi-line.html +223 -0
- package/skill/recipes/pyramid.html +156 -0
- package/skill/recipes/race.html +272 -0
- package/skill/recipes/radial-bars.html +151 -0
- package/skill/recipes/scatter.html +193 -0
- package/skill/recipes/slope.html +193 -0
- package/skill/recipes/sparklines.html +169 -0
- package/skill/recipes/stacked-100.html +152 -0
- package/skill/recipes/stacked-bars.html +144 -0
- package/skill/recipes/strip.html +215 -0
- package/skill/recipes/violin.html +163 -0
- package/skill/recipes/waffle.html +159 -0
- package/skill/recipes/waterfall.html +121 -0
- package/skill/references/api.md +1556 -0
- package/skill/references/design.md +763 -0
- package/skill/references/environments.md +1570 -0
- package/skill/references/forms.md +160 -0
- package/skill/references/interaction.md +792 -0
- package/skill/references/pitfalls.md +157 -0
- package/src/check/README.md +163 -0
- package/src/check/browser.js +92 -0
- package/src/check/build.js +439 -0
- package/src/check/colors.js +324 -0
- package/src/check/findings.js +247 -0
- package/src/check/index.js +419 -0
- package/src/check/inpage.js +1268 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +103 -0
- package/src/check/source.js +387 -0
- package/src/check/sourcemap.js +75 -0
- package/src/guard/note.js +30 -0
- package/src/guard/render.js +17 -0
- package/src/guard/rhp.js +608 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +265 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Weekend screen time</title>
|
|
7
|
+
<meta name="description" content="Grouped bar chart, two bars per category with the gap between them in the end gutter, and a switch that sorts the groups. Use it to compare two to four values side by side for each category.">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400..800&family=Unbounded:wght@600..800&display=swap">
|
|
11
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
12
|
+
<style>
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body { margin: 0; padding: 48px 16px; background: #e2e0db; font-family: Manrope, "Segoe UI", system-ui, sans-serif; }
|
|
15
|
+
/* The poster: pale paper with a pixel grid, cobalt for weekdays, orange for weekends, a sticker for the headline number */
|
|
16
|
+
.poster { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 32px; max-width: 960px; margin: 0 auto; padding: 46px 52px 30px; color: #17161c; border-radius: 22px;
|
|
17
|
+
background: radial-gradient(circle, rgb(23 22 28 / .08) 1px, transparent 1.5px) 0 0 / 14px 14px, #f7f6f2;
|
|
18
|
+
box-shadow: 0 26px 50px -30px rgb(70 66 58 / .6); }
|
|
19
|
+
.poster > * { grid-column: 1 / -1; min-width: 0; }
|
|
20
|
+
.poster figcaption { grid-column: 1; display: grid; gap: 14px; margin-bottom: 26px; }
|
|
21
|
+
.poster .kicker { font-size: 12px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; color: #2d43c4; }
|
|
22
|
+
.poster .headline { max-width: 15em; font: 700 44px/1.06 Unbounded, "Arial Black", system-ui, sans-serif; letter-spacing: -.02em; text-wrap: balance; }
|
|
23
|
+
.poster .dek { max-width: 54ch; font-size: 16px; line-height: 1.55; color: #47454f; }
|
|
24
|
+
.poster .note { display: block; margin-top: 22px; font-size: 12px; line-height: 1.5; color: #6c6975; }
|
|
25
|
+
/* The sticker: the largest weekend gain on a starburst, the burst turned, the words level */
|
|
26
|
+
.sticker { position: relative; isolation: isolate; grid-column: 2; grid-row: 1; align-self: start; display: grid; place-content: center; gap: 6px; width: 172px; height: 172px; margin: 0; color: #f7f6f2; text-align: center; font-size: 12px; line-height: 1.3; }
|
|
27
|
+
.sticker::before { content: ""; position: absolute; inset: 0; z-index: -1; background: #17161c; rotate: -9deg;
|
|
28
|
+
clip-path: polygon(50% 0, 59.6% 13.9%, 75% 6.7%, 76.3% 23.7%, 93.3% 25%, 86.1% 40.4%, 100% 50%, 86.1% 59.6%, 93.3% 75%, 76.3% 76.3%, 75% 93.3%, 59.6% 86.1%, 50% 100%, 40.4% 86.1%, 25% 93.3%, 23.7% 76.3%, 6.7% 75%, 13.9% 59.6%, 0 50%, 13.9% 40.4%, 6.7% 25%, 23.7% 23.7%, 25% 6.7%, 40.4% 13.9%); }
|
|
29
|
+
.sticker b { font: 700 20px/1.2 Unbounded, "Arial Black", system-ui, sans-serif; color: #ff8a4c; }
|
|
30
|
+
/* The key and the sort switch */
|
|
31
|
+
.tools { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px 24px; margin-bottom: 14px; }
|
|
32
|
+
.key { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 0; font-size: 14px; font-weight: 700; }
|
|
33
|
+
.key i { display: inline-block; width: 22px; height: 10px; margin-right: 7px; border-radius: 5px; }
|
|
34
|
+
.sort { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border-radius: 999px; background: #e9e7e1; font-size: 13px; font-weight: 700; color: #5f5c68; }
|
|
35
|
+
.sort span { padding: 0 8px 0 10px; }
|
|
36
|
+
.sort button { padding: 7px 13px; border: 0; white-space: nowrap; border-radius: 999px; background: none; color: #47454f; font: 700 13px/1 Manrope, system-ui, sans-serif; cursor: pointer; }
|
|
37
|
+
.sort button[aria-pressed="true"] { background: #17161c; color: #f7f6f2; }
|
|
38
|
+
.sort button:focus-visible { outline: 2px solid #2d43c4; outline-offset: 2px; }
|
|
39
|
+
@media (max-width: 640px) {
|
|
40
|
+
body { padding: 16px 16px 32px; }
|
|
41
|
+
.poster { column-gap: 10px; padding: 26px 16px 18px; border-radius: 16px; }
|
|
42
|
+
.poster figcaption { grid-column: 1 / -1; margin-bottom: 18px; }
|
|
43
|
+
.poster .headline { font-size: 27px; }
|
|
44
|
+
.poster .dek { font-size: 15px; }
|
|
45
|
+
.sticker { grid-row: 2; width: 132px; height: 132px; gap: 3px; font-size: 11px; line-height: 1.2; }
|
|
46
|
+
.sticker b { font-size: 15px; }
|
|
47
|
+
.tools { grid-column: 1; grid-row: 2; flex-direction: column; align-items: flex-start; align-self: center; }
|
|
48
|
+
.sort span { display: none; }
|
|
49
|
+
.sort button { padding: 7px 11px; }
|
|
50
|
+
}
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
<div id="chart"></div>
|
|
55
|
+
<script type="module">
|
|
56
|
+
import { Chart, Plot, Bar, Label, Poster, slat, sortBy, every, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
57
|
+
|
|
58
|
+
// Data: average minutes a day on screens outside work and school, by age group. Illustrative data; replace with your own.
|
|
59
|
+
const GROUPS = [
|
|
60
|
+
{ age: "13-17", weekday: 345, weekend: 455 },
|
|
61
|
+
{ age: "18-24", weekday: 340, weekend: 410 },
|
|
62
|
+
{ age: "25-34", weekday: 230, weekend: 365 },
|
|
63
|
+
{ age: "35-44", weekday: 195, weekend: 320 },
|
|
64
|
+
{ age: "45-54", weekday: 200, weekend: 290 },
|
|
65
|
+
{ age: "55-64", weekday: 225, weekend: 275 },
|
|
66
|
+
{ age: "65+", weekday: 270, weekend: 280 },
|
|
67
|
+
];
|
|
68
|
+
const DAYS = [ // the bars of each group, in order
|
|
69
|
+
{ key: "weekday", name: "Weekday", color: "#2d43c4" },
|
|
70
|
+
{ key: "weekend", name: "Weekend", color: "#e8600f" },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const time = (m) => (m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`.replace(" 0m", ""));
|
|
74
|
+
const gain = (d) => d.weekend - d.weekday;
|
|
75
|
+
const most = GROUPS.reduce((a, d) => (gain(d) > gain(a) ? d : a));
|
|
76
|
+
// The scale: whole 2-hour steps, with 45 minutes past the longest bar for its time, so it stays clear of the gains
|
|
77
|
+
const max = Math.ceil((Math.max(...GROUPS.flatMap((d) => DAYS.map((x) => d[x.key]))) + 45) / 120) * 120;
|
|
78
|
+
|
|
79
|
+
// Slat types: a day's bar with its time, and an age group holding its days' bars (a Plot inside the slat) and its gain
|
|
80
|
+
const Day = slat({
|
|
81
|
+
inset: "5px",
|
|
82
|
+
css: `
|
|
83
|
+
.bar { --rhp-radius: 99px; }
|
|
84
|
+
.time { font-size: 12px; font-weight: 600; color: #6c6975; font-variant-numeric: tabular-nums; }
|
|
85
|
+
`,
|
|
86
|
+
}, (b) => html`
|
|
87
|
+
<div>
|
|
88
|
+
<${Bar} to=${() => b.minutes} color=${() => b.color} class="bar" />
|
|
89
|
+
<${Label} at=${() => b.minutes} class="time">${() => time(b.minutes)}<//>
|
|
90
|
+
</div>`);
|
|
91
|
+
|
|
92
|
+
const Group = slat({
|
|
93
|
+
thickness: 64,
|
|
94
|
+
room: "auto",
|
|
95
|
+
css: `
|
|
96
|
+
.age { font: 700 15px/1 Unbounded, "Arial Black", system-ui, sans-serif; --rhp-label-gap: 14px; }
|
|
97
|
+
.gain { font-size: 13px; font-weight: 800; color: #17161c; font-variant-numeric: tabular-nums; --rhp-label-gap: 12px; }
|
|
98
|
+
@container (max-width: 420px) { .age { font-size: 13px; --rhp-label-gap: 10px; } .gain { font-size: 12px; --rhp-label-gap: 8px; } }
|
|
99
|
+
`,
|
|
100
|
+
}, (d) => html`
|
|
101
|
+
<div aria-label=${() => d.age + ": " + DAYS.map((x) => `${x.name.toLowerCase()} ${time(d[x.key])}`).join(", ")}>
|
|
102
|
+
<${Label} edge="start" class="age">${() => d.age}<//>
|
|
103
|
+
<${Plot} color=${DAYS.map((x) => x.color)} minutes=${() => DAYS.map((x) => d[x.key])}>${Day}<//>
|
|
104
|
+
<${Label} edge="end" class="gain">${() => "+" + time(gain(d))}<//>
|
|
105
|
+
</div>`);
|
|
106
|
+
|
|
107
|
+
// Chart component: the switch sorts the age groups by their weekend gain, and they slide to their new places
|
|
108
|
+
function WeekendScreens() {
|
|
109
|
+
const [byGain, setByGain] = createSignal(false);
|
|
110
|
+
return html`
|
|
111
|
+
<${Poster}
|
|
112
|
+
kicker="Screen time, by age"
|
|
113
|
+
title="Weekends add the most screen time for 25 to 44 year olds"
|
|
114
|
+
dek="Average time a day on screens outside work and school, on a weekday and on a weekend day. Teenagers spend the most; adults with a working week gain the most when it ends."
|
|
115
|
+
note="Illustrative data. The number after each group is how much longer its weekend days are. Sort the groups with the switch.">
|
|
116
|
+
<p class="sticker"><b>${"+" + time(gain(most))}</b><span>a weekend day,<br>ages ${most.age}</span></p>
|
|
117
|
+
<div class="tools">
|
|
118
|
+
<p class="key">${DAYS.map((x) => html`<span><i style=${"background: " + x.color}></i>${x.name}</span>`)}</p>
|
|
119
|
+
<div class="sort" role="group" aria-label="Sort the age groups">
|
|
120
|
+
<span aria-hidden="true">Sort</span>
|
|
121
|
+
<button type="button" aria-pressed=${() => !byGain()} onClick=${() => setByGain(false)}>Age</button>
|
|
122
|
+
<button type="button" aria-pressed=${() => byGain()} onClick=${() => setByGain(true)}>Weekend gain</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<${Chart} scale=${[0, max]} ticks=${every(120)} format=${(v) => v / 60 + "h"}
|
|
126
|
+
label="Screen time a day by age group, weekdays and weekends"
|
|
127
|
+
theme=${{ font: 'Manrope, "Segoe UI", system-ui, sans-serif', ink: "#17161c", muted: "#6c6975", grid: "#dedbd3" }}>
|
|
128
|
+
<${Plot} rows=${GROUPS} key="age" order=${() => (byGain() ? sortBy(gain, "desc") : null)}>${Group}<//>
|
|
129
|
+
<//>
|
|
130
|
+
<//>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
render(() => html`<${WeekendScreens} />`, document.getElementById("chart"));
|
|
134
|
+
</script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>When the code gets written</title>
|
|
7
|
+
<meta name="description" content="Heatmap: a value over two categories (weekday by hour), one colored cell each, with a readout of the cell the reader points at or taps and of the day in keyboard focus. It turns its days into columns on a phone. Use it for activity by day and hour, or any grid of counts.">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@500..800&family=JetBrains+Mono:wght@400..700&display=swap">
|
|
11
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
12
|
+
<style>
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body { margin: 0; padding: 48px 16px; background: #c9c8c3; font-family: Sora, "Helvetica Neue", Arial, sans-serif; }
|
|
15
|
+
/* The poster: a keyboard on a desk, keys lit by how often they were pressed */
|
|
16
|
+
.poster { display: grid; max-width: 1000px; margin: 0 auto; padding: 44px 48px 30px; border-radius: 18px; color: #1c1b22;
|
|
17
|
+
background: linear-gradient(180deg, #f4f3f0, #e9e8e4); box-shadow: 0 1px 2px rgb(30 29 26 / .2), 0 26px 48px -28px rgb(30 29 26 / .6); }
|
|
18
|
+
.poster > * { min-width: 0; }
|
|
19
|
+
.poster figcaption { display: grid; gap: 12px; margin-bottom: 22px; }
|
|
20
|
+
.poster .kicker { font: 600 12px/1.3 "JetBrains Mono", ui-monospace, Menlo, monospace; letter-spacing: .04em; color: #4f46c8; }
|
|
21
|
+
.poster .headline { font-size: 46px; font-weight: 750; line-height: 1.04; letter-spacing: -.035em; text-wrap: balance; }
|
|
22
|
+
/* A text cursor after the headline */
|
|
23
|
+
.poster .headline::after { content: ""; display: inline-block; width: .42em; height: .82em; margin-left: .12em; vertical-align: -.06em; background: #4f46c8; animation: blink 1.1s steps(1) infinite; }
|
|
24
|
+
@keyframes blink { 50% { opacity: 0; } }
|
|
25
|
+
@media (prefers-reduced-motion: reduce) { .poster .headline::after { animation: none; } }
|
|
26
|
+
.poster .dek { max-width: 60ch; font-size: 16px; line-height: 1.55; color: #4a4955; }
|
|
27
|
+
.poster .note { display: block; margin-top: 18px; font-size: 12px; line-height: 1.5; color: #5f5e69; }
|
|
28
|
+
/* One line above the keys: the hour or day being read, and the color key */
|
|
29
|
+
.readout { display: flex; align-items: center; gap: 8px 18px; flex-wrap: wrap; margin-bottom: 12px; font: 500 14px/1.4 "JetBrains Mono", ui-monospace, Menlo, monospace; }
|
|
30
|
+
.readout b { font-weight: 700; }
|
|
31
|
+
.readout output { flex: 1 1 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
32
|
+
/* Keep the key's height while centering its numbers on a whole-pixel line box. */
|
|
33
|
+
.readout .key { display: inline-flex; align-items: center; gap: 6px; height: 1.4em; font-size: 12px; line-height: 1; color: #5f5e69; }
|
|
34
|
+
.readout .key i { width: 88px; height: 10px; border-radius: 3px; background: linear-gradient(90deg in oklab, #f7f6f3, #3730a3); box-shadow: inset 0 0 0 1px rgb(28 27 34 / .14); }
|
|
35
|
+
/* The keyboard's case around the keys */
|
|
36
|
+
.chart { border-radius: 14px; background: #d9d8d3; box-shadow: inset 0 1px 3px rgb(30 29 26 / .25), 0 1px 0 #fff; } /* its padding is the chart's room, set by the slat types */
|
|
37
|
+
/* Where the days turn into columns, the chart is taller than half a phone's screen: the readout stays at the top of
|
|
38
|
+
the screen while the chart scrolls under it (the poster is a flex column there: a sticky grid item can't leave its row) */
|
|
39
|
+
@media (max-width: 639px) {
|
|
40
|
+
.poster { display: flex; flex-direction: column; }
|
|
41
|
+
.readout { position: sticky; top: 0; z-index: 2; margin: -8px 0 4px; padding: 8px 0; background: #f0efeb; }
|
|
42
|
+
}
|
|
43
|
+
@media (max-width: 520px) {
|
|
44
|
+
body { padding: 24px 16px 32px; }
|
|
45
|
+
.poster { padding: 30px 18px 20px; }
|
|
46
|
+
.poster .headline { font-size: 32px; }
|
|
47
|
+
.poster .dek { font-size: 15px; }
|
|
48
|
+
.readout { font-size: 13px; }
|
|
49
|
+
}
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<div id="chart"></div>
|
|
54
|
+
<script type="module">
|
|
55
|
+
import { Chart, Plot, Cell, Label, Poster, slat, nice, useOrientation, html, render, createSignal, onCleanup, Show } from "@bezda/rhp/standalone";
|
|
56
|
+
|
|
57
|
+
// Data: commits made in one year in a team's repository, by weekday and by hour of the day (0 is midnight to 1 am),
|
|
58
|
+
// in local time. Illustrative. Replace with your own: one list of 24 numbers per day.
|
|
59
|
+
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
60
|
+
const COMMITS = [
|
|
61
|
+
[5, 8, 11, 6, 9, 5, 8, 13, 29, 123, 225, 218, 59, 102, 270, 346, 292, 157, 46, 22, 13, 41, 52, 22],
|
|
62
|
+
[10, 5, 8, 11, 7, 10, 6, 11, 44, 125, 258, 229, 55, 124, 285, 390, 309, 162, 63, 15, 27, 36, 48, 29],
|
|
63
|
+
[7, 10, 5, 8, 11, 7, 10, 8, 35, 134, 242, 234, 44, 112, 289, 370, 312, 148, 53, 27, 18, 47, 38, 25],
|
|
64
|
+
[11, 6, 9, 5, 8, 11, 7, 12, 25, 122, 247, 219, 55, 100, 272, 371, 294, 156, 43, 18, 30, 37, 49, 21],
|
|
65
|
+
[6, 9, 5, 8, 3, 6, 9, 7, 31, 87, 188, 184, 36, 90, 206, 285, 243, 115, 45, 6, 17, 41, 31, 21],
|
|
66
|
+
[5, 8, 11, 7, 10, 5, 9, 12, 6, 18, 13, 35, 59, 61, 76, 57, 51, 43, 17, 18, 2, 9, 17, 7],
|
|
67
|
+
[10, 5, 8, 11, 7, 10, 5, 8, 17, 5, 15, 8, 25, 46, 46, 59, 41, 38, 36, 27, 64, 72, 57, 26],
|
|
68
|
+
];
|
|
69
|
+
const SCALE = nice(0, Math.max(...COMMITS.flat())); // 0 to 400: the lightest key is 0, the darkest the top
|
|
70
|
+
const count = new Intl.NumberFormat("en-US");
|
|
71
|
+
const clock = (h) => String(h).padStart(2, "0") + ":00";
|
|
72
|
+
const busiest = (day) => COMMITS[day].indexOf(Math.max(...COMMITS[day]));
|
|
73
|
+
|
|
74
|
+
// Slat types: an hour is one key, its color from the theme's low to its high by commits. The first day's hours carry
|
|
75
|
+
// the hour numbers, every third hour. A day is its name and its 24 hours, in a Plot across the day's slat.
|
|
76
|
+
const Hour = slat({
|
|
77
|
+
css: `
|
|
78
|
+
.key { --rhp-cell-gap: 2px; --rhp-radius: 5px; box-shadow: inset 0 -3px 0 rgb(28 27 34 / .17), inset 0 1px 0 rgb(255 255 255 / .45); }
|
|
79
|
+
.hour:hover .key { box-shadow: inset 0 -1px 0 rgb(28 27 34 / .17), inset 0 2px 4px rgb(28 27 34 / .3); }
|
|
80
|
+
.clock { font: 500 11px/1 "JetBrains Mono", ui-monospace, Menlo, monospace; color: var(--rhp-muted); pointer-events: none; }
|
|
81
|
+
.clock:vertical { --rhp-label-gap: 6px; }
|
|
82
|
+
.clock:horizontal { width: auto; } /* an edge Label is as wide as the chart's start room, which a vertical chart doesn't have */
|
|
83
|
+
`,
|
|
84
|
+
}, (h) => {
|
|
85
|
+
const o = useOrientation(); // the hours' own orientation: across the days'
|
|
86
|
+
return html`
|
|
87
|
+
<div class="hour" data-day=${() => h.day} data-hour=${() => h.index}>
|
|
88
|
+
<${Cell} value=${() => h.commits} class="key" />
|
|
89
|
+
<${Show} when=${() => h.day === 0 && h.index % 3 === 0}>
|
|
90
|
+
<${Label} edge=${() => (o() === "vertical" ? "end" : "start")} class="clock">${() => h.index}<//>
|
|
91
|
+
<//>
|
|
92
|
+
</div>`;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const Day = slat({
|
|
96
|
+
thickness: { horizontal: 32 },
|
|
97
|
+
room: { horizontal: { start: 44, before: 20 }, vertical: { end: 22, before: 26 } },
|
|
98
|
+
css: `
|
|
99
|
+
.name { font: 600 12px/1 "JetBrains Mono", ui-monospace, Menlo, monospace; }
|
|
100
|
+
.day:focus-visible { outline: 2px solid #4f46c8; outline-offset: 1px; border-radius: 7px; }
|
|
101
|
+
`,
|
|
102
|
+
}, (d) => {
|
|
103
|
+
const o = useOrientation();
|
|
104
|
+
return html`
|
|
105
|
+
<div class="day" data-row=${() => d.index}>
|
|
106
|
+
<${Label} edge=${() => (o() === "vertical" ? "end" : "start")} class="name">${() => d.day.slice(0, 3)}<//>
|
|
107
|
+
<${Plot} orientation="across" day=${() => d.index} commits=${() => d.hours}>${Hour}<//>
|
|
108
|
+
</div>`;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Chart component: the readout follows the hour the reader points at or taps, or the day in keyboard focus. On a
|
|
112
|
+
// narrow screen the days turn into columns, so the hours run down the page.
|
|
113
|
+
function Commits() {
|
|
114
|
+
const wide = matchMedia("(min-width: 640px)");
|
|
115
|
+
const [horizontal, setHorizontal] = createSignal(wide.matches);
|
|
116
|
+
const turn = (e) => setHorizontal(e.matches);
|
|
117
|
+
wide.addEventListener("change", turn);
|
|
118
|
+
onCleanup(() => wide.removeEventListener("change", turn));
|
|
119
|
+
|
|
120
|
+
const [at, setAt] = createSignal(null); // [day, hour], or [day] for a whole day
|
|
121
|
+
// A mouse or a pen picks as it moves, a tap or a click picks; between the keys the pick stays
|
|
122
|
+
const pick = (e) => {
|
|
123
|
+
const hour = e.target.closest("[data-hour]");
|
|
124
|
+
const day = e.target.closest("[data-row]");
|
|
125
|
+
if (hour) setAt([+hour.dataset.day, +hour.dataset.hour]);
|
|
126
|
+
else if (day) setAt([+day.dataset.row]);
|
|
127
|
+
};
|
|
128
|
+
// What the day with focus holds: the whole day the keyboard reached, or the hour a click or a tap picked in it
|
|
129
|
+
let held = null;
|
|
130
|
+
// Focus reads a whole day, unless an hour of that day is already picked (a tap on an hour also focuses its day)
|
|
131
|
+
const focusDay = (e) => {
|
|
132
|
+
const day = e.target.closest("[data-row]");
|
|
133
|
+
if (day && at()?.[0] !== +day.dataset.row) setAt([+day.dataset.row]);
|
|
134
|
+
held = at();
|
|
135
|
+
};
|
|
136
|
+
const click = (e) => {
|
|
137
|
+
pick(e);
|
|
138
|
+
held = at();
|
|
139
|
+
};
|
|
140
|
+
// Leaving the poster: back to what the day with focus holds (a keyboard's or a click's pick), or none
|
|
141
|
+
const leave = (e) => {
|
|
142
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
143
|
+
setAt(e.currentTarget.contains(document.activeElement) ? held : null);
|
|
144
|
+
};
|
|
145
|
+
const readout = () => {
|
|
146
|
+
const [day, hour] = at() ?? [1, 15];
|
|
147
|
+
const lead = at() ? "" : "Busiest hour: ";
|
|
148
|
+
if (hour == null) {
|
|
149
|
+
const total = COMMITS[day].reduce((sum, v) => sum + v, 0);
|
|
150
|
+
return html`<b>${DAYS[day]}</b> · ${count.format(total)} commits, most at ${clock(busiest(day))}`;
|
|
151
|
+
}
|
|
152
|
+
return html`${lead}<b>${DAYS[day].slice(0, 3) + " " + clock(hour)}</b> · ${count.format(COMMITS[day][hour])} commits`;
|
|
153
|
+
};
|
|
154
|
+
return html`
|
|
155
|
+
<${Poster}
|
|
156
|
+
kicker="$ git log --since=1.year"
|
|
157
|
+
title="More than half of all commits land on weekday afternoons"
|
|
158
|
+
dek="One team's commits over a year, by weekday and hour. The keys darken from 2 to 5 pm on every working day, the lunch hour dips, and weekends barely move."
|
|
159
|
+
note="Illustrative data, in local time. Point at a key or tap it to read its hour; Tab into the chart and use the arrow keys to read a day."
|
|
160
|
+
onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${click} onFocusIn=${focusDay}
|
|
161
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setAt(null)}>
|
|
162
|
+
<div class="readout">
|
|
163
|
+
<output aria-live="polite">${readout}</output>
|
|
164
|
+
<span class="key" aria-hidden="true">0<i></i>${count.format(SCALE.max)}</span>
|
|
165
|
+
</div>
|
|
166
|
+
<${Chart} class="chart" orientation=${() => (horizontal() ? "horizontal" : "vertical")} height=${24 * 19}
|
|
167
|
+
scale=${[SCALE.min, SCALE.max]} ticks=${false} label="Commits by weekday and hour of the day"
|
|
168
|
+
theme=${{ font: "Sora, 'Helvetica Neue', Arial, sans-serif", ink: "#1c1b22", muted: "#55545e", surface: "#d9d8d3", low: "#f7f6f3", high: "#3730a3" }}>
|
|
169
|
+
<${Plot} keyboard=${true} day=${DAYS} hours=${COMMITS}>${Day}<//>
|
|
170
|
+
<//>
|
|
171
|
+
<//>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
render(() => html`<${Commits} />`, document.getElementById("chart"));
|
|
175
|
+
</script>
|
|
176
|
+
</body>
|
|
177
|
+
</html>
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Runners sprint to beat four hours</title>
|
|
7
|
+
<meta name="description" content="Histogram of marathon finish times made with bins(), one column per 5-minute bin, with a readout of the picked bin and the share of runners ahead of it. Use it to show how one measure is distributed and where values bunch.">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..900&display=swap">
|
|
11
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
12
|
+
<style>
|
|
13
|
+
:root { color-scheme: light; }
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
/* The page is a dark running vest; the poster is the race bib pinned to it */
|
|
16
|
+
body { margin: 0; padding: 48px 16px; font-family: Archivo, "Helvetica Neue", Arial, sans-serif; color: #141416;
|
|
17
|
+
background: #1d1e22 radial-gradient(rgb(255 255 255 / .05) 1px, transparent 1.5px) 0 0 / 7px 7px; }
|
|
18
|
+
.poster { position: relative; max-width: 1000px; margin: 0 auto; padding: 44px 52px 30px; border-radius: 14px; line-height: 1.45;
|
|
19
|
+
background:
|
|
20
|
+
radial-gradient(circle, #1d1e22 0 5px, #cfcfca 5.5px 7.5px, transparent 8px) left 10px top 10px / 26px 26px no-repeat,
|
|
21
|
+
radial-gradient(circle, #1d1e22 0 5px, #cfcfca 5.5px 7.5px, transparent 8px) right 10px top 10px / 26px 26px no-repeat,
|
|
22
|
+
radial-gradient(circle, #1d1e22 0 5px, #cfcfca 5.5px 7.5px, transparent 8px) left 10px bottom 10px / 26px 26px no-repeat,
|
|
23
|
+
radial-gradient(circle, #1d1e22 0 5px, #cfcfca 5.5px 7.5px, transparent 8px) right 10px bottom 10px / 26px 26px no-repeat,
|
|
24
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='480' height='480'%3E%3Cfilter id='f' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.04' numOctaves='4' seed='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .07 0 0 0 -.025'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E"),
|
|
25
|
+
#fcfcfa;
|
|
26
|
+
box-shadow: 0 24px 60px -20px rgb(0 0 0 / .6); }
|
|
27
|
+
.poster figcaption { display: grid; gap: 10px; margin-bottom: 26px; }
|
|
28
|
+
.poster .kicker { font-size: 13px; font-weight: 800; letter-spacing: .2em; text-transform: uppercase; color: #d9302a; }
|
|
29
|
+
.poster .headline { font-size: 84px; font-weight: 900; font-stretch: 62%; line-height: .9; letter-spacing: -.005em; text-transform: uppercase; text-wrap: balance; }
|
|
30
|
+
.poster .dek { max-width: 60ch; font-size: 16px; color: #45454b; }
|
|
31
|
+
.poster .dek i { display: inline-block; width: 11px; height: 11px; margin: 0 2px 0 1px; border-radius: 2px; background: #d9302a; vertical-align: -1px; }
|
|
32
|
+
.poster .note { display: block; margin-top: 20px; font-size: 12px; color: #6a6a70; }
|
|
33
|
+
/* The readout: a finish-line clock for the picked column */
|
|
34
|
+
.board { display: grid; grid-template-columns: 1.5fr 1fr 1.1fr; margin: 0 0 22px; border-block: 3px solid #141416; }
|
|
35
|
+
.board div { display: grid; padding: 10px 14px 9px; border-left: 1px solid #d4d4cf; }
|
|
36
|
+
.board div:first-child { padding-left: 0; border-left: 0; }
|
|
37
|
+
.board b { font-size: 44px; font-weight: 800; font-stretch: 62%; line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
38
|
+
.board span { font-size: 11px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: #6a6a70; }
|
|
39
|
+
.axis-name { margin: 0 0 6px; font-size: 11px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: #6a6a70; }
|
|
40
|
+
.chart { touch-action: pan-y; } /* a finger dragging sideways moves through the columns; up and down still scrolls */
|
|
41
|
+
@media (max-width: 600px) {
|
|
42
|
+
body { padding: 16px; }
|
|
43
|
+
.poster { padding: 38px 18px 36px; border-radius: 10px; }
|
|
44
|
+
.poster figcaption { margin-bottom: 18px; }
|
|
45
|
+
.poster .kicker { font-size: 11px; letter-spacing: .16em; }
|
|
46
|
+
.poster .headline { font-size: 46px; }
|
|
47
|
+
.poster .dek { font-size: 14.5px; }
|
|
48
|
+
.board { grid-template-columns: 1fr 1fr; }
|
|
49
|
+
.board div:first-child { grid-column: 1 / -1; border-bottom: 1px solid #d4d4cf; }
|
|
50
|
+
.board div:nth-child(2) { padding-left: 0; border-left: 0; }
|
|
51
|
+
.board div { padding: 8px 8px 7px; }
|
|
52
|
+
.board b { font-size: 30px; }
|
|
53
|
+
.board span { font-size: 10.5px; letter-spacing: .08em; }
|
|
54
|
+
}
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<div id="chart"></div>
|
|
59
|
+
<script type="module">
|
|
60
|
+
import { Chart, Plot, Bar, Label, Poster, Show, slat, bins, nice, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
61
|
+
|
|
62
|
+
// Data: finish times of a city marathon in minutes, one per runner, in finishing order. Illustrative. Replace with your own.
|
|
63
|
+
const FINISH_TIMES = [
|
|
64
|
+
153, 156, 158, 160, 161, 163, 164, 165, 166, 167, 168, 169, 169, 170, 171, 172, 173, 173, 174, 175, 176, 176, 177, 177,
|
|
65
|
+
178, 178, 179, 179, 179, 181, 182, 183, 184, 185, 186, 186, 187, 188, 188, 189, 190, 190, 191, 192, 192, 193, 193, 194,
|
|
66
|
+
195, 195, 196, 196, 197, 197, 198, 198, 199, 199, 200, 200, 200, 201, 201, 202, 202, 203, 203, 203, 204, 204, 204, 205,
|
|
67
|
+
205, 206, 206, 206, 207, 207, 207, 207, 207, 208, 208, 208, 208, 209, 209, 209, 209, 209, 209, 210, 210, 211, 211, 212,
|
|
68
|
+
212, 212, 213, 213, 214, 214, 214, 215, 215, 215, 215, 216, 216, 216, 216, 217, 217, 217, 218, 218, 218, 218, 219, 219,
|
|
69
|
+
219, 219, 219, 220, 220, 220, 220, 221, 221, 221, 221, 222, 222, 222, 222, 222, 223, 223, 223, 223, 223, 224, 224, 224,
|
|
70
|
+
224, 225, 225, 225, 225, 225, 226, 226, 226, 226, 226, 227, 227, 227, 227, 227, 228, 228, 228, 228, 228, 229, 229, 229,
|
|
71
|
+
229, 229, 230, 230, 230, 230, 230, 230, 231, 231, 231, 231, 231, 232, 232, 232, 232, 232, 233, 233, 233, 233, 233, 233,
|
|
72
|
+
234, 234, 234, 234, 234, 234, 235, 235, 235, 235, 235, 236, 236, 236, 236, 236, 236, 236, 237, 237, 237, 237, 237, 237,
|
|
73
|
+
237, 237, 237, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 239, 239, 239, 239, 239, 239, 239, 239, 239, 239,
|
|
74
|
+
240, 240, 240, 241, 241, 241, 242, 242, 242, 242, 243, 243, 243, 243, 244, 244, 244, 244, 244, 245, 245, 245, 245, 245,
|
|
75
|
+
246, 246, 246, 246, 246, 246, 247, 247, 247, 247, 247, 248, 248, 248, 248, 248, 248, 248, 249, 249, 249, 249, 249, 249,
|
|
76
|
+
250, 250, 250, 250, 250, 250, 250, 251, 251, 251, 251, 251, 252, 252, 252, 252, 252, 252, 252, 252, 253, 253, 253, 253,
|
|
77
|
+
253, 253, 254, 254, 254, 254, 254, 254, 255, 255, 255, 255, 255, 255, 256, 256, 256, 256, 256, 256, 256, 257, 257, 257,
|
|
78
|
+
257, 257, 257, 258, 258, 258, 258, 258, 258, 259, 259, 259, 259, 259, 259, 259, 260, 260, 260, 260, 260, 260, 260, 261,
|
|
79
|
+
261, 261, 261, 261, 262, 262, 262, 262, 262, 262, 262, 263, 263, 263, 263, 263, 263, 264, 264, 264, 264, 264, 264, 264,
|
|
80
|
+
265, 265, 265, 265, 265, 266, 266, 266, 266, 266, 266, 266, 267, 267, 267, 267, 267, 267, 267, 267, 268, 268, 268, 268,
|
|
81
|
+
268, 268, 268, 268, 268, 269, 269, 269, 269, 269, 269, 269, 269, 270, 270, 270, 270, 271, 271, 271, 271, 271, 272, 272,
|
|
82
|
+
272, 272, 273, 273, 273, 273, 274, 274, 274, 274, 274, 274, 275, 275, 275, 275, 275, 276, 276, 276, 276, 276, 277, 277,
|
|
83
|
+
277, 277, 277, 278, 278, 278, 278, 278, 278, 278, 279, 279, 279, 279, 279, 280, 280, 280, 280, 280, 281, 281, 281, 281,
|
|
84
|
+
281, 282, 282, 282, 282, 282, 282, 283, 283, 283, 283, 283, 284, 284, 284, 284, 284, 285, 285, 285, 285, 285, 286, 286,
|
|
85
|
+
286, 286, 286, 287, 287, 287, 287, 287, 288, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 290, 291, 291,
|
|
86
|
+
291, 291, 292, 292, 292, 292, 292, 293, 293, 293, 293, 293, 294, 294, 294, 294, 295, 295, 295, 295, 296, 296, 296, 296,
|
|
87
|
+
296, 297, 297, 297, 297, 297, 298, 298, 298, 298, 298, 298, 299, 299, 299, 299, 299, 299, 300, 300, 300, 301, 301, 302,
|
|
88
|
+
302, 302, 303, 303, 303, 304, 304, 304, 305, 305, 305, 306, 306, 306, 307, 307, 307, 308, 308, 308, 308, 309, 309, 309,
|
|
89
|
+
310, 310, 310, 311, 311, 311, 312, 312, 312, 313, 313, 313, 314, 314, 315, 315, 315, 315, 316, 316, 317, 317, 317, 318,
|
|
90
|
+
318, 319, 319, 319, 320, 320, 321, 321, 321, 322, 322, 323, 323, 324, 324, 325, 325, 325, 326, 326, 327, 327, 328, 328,
|
|
91
|
+
329, 329, 329, 330, 331, 331, 332, 333, 333, 334, 335, 335, 336, 337, 337, 338, 339, 339, 340, 341, 341, 342, 343, 344,
|
|
92
|
+
345, 345, 347, 347, 348, 349, 350, 351, 352, 353, 355, 356, 357, 359, 360, 361, 363, 364, 366, 368, 370, 372, 374, 377,
|
|
93
|
+
380, 383, 386,
|
|
94
|
+
];
|
|
95
|
+
const BIN = 5; // minutes per column
|
|
96
|
+
const GOAL = 30; // round times runners aim for: every half hour
|
|
97
|
+
const FOCUS = 235; // the column the poster opens on: 3:55 to 3:59
|
|
98
|
+
|
|
99
|
+
// Bins on whole half hours around the data: { x0, x1, tally }, bin k from x0[k] up to (not including) x1[k]
|
|
100
|
+
const start = Math.floor(Math.min(...FINISH_TIMES) / GOAL) * GOAL;
|
|
101
|
+
const end = Math.ceil((Math.max(...FINISH_TIMES) + 1) / GOAL) * GOAL;
|
|
102
|
+
const { x0, x1, tally } = bins(FINISH_TIMES, { domain: [start, end], count: (end - start) / BIN });
|
|
103
|
+
const top = nice(0, Math.max(...tally) * 1.12); // room above the tallest column for its count
|
|
104
|
+
const ahead = tally.map((_, k) => tally.slice(0, k).reduce((sum, n) => sum + n, 0)); // runners in the columns before
|
|
105
|
+
const opening = Math.max(0, x0.indexOf(FOCUS));
|
|
106
|
+
|
|
107
|
+
const clock = (minutes) => `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`;
|
|
108
|
+
const count = new Intl.NumberFormat("en-US");
|
|
109
|
+
const percent = new Intl.NumberFormat("en-US", { style: "percent" });
|
|
110
|
+
|
|
111
|
+
// Slat type: one column per bin; red for the last minutes before a round time, pale once past the picked column
|
|
112
|
+
const Bin = slat({
|
|
113
|
+
inset: "1px",
|
|
114
|
+
room: { start: 26, end: 12, after: 12 }, // after: half of the count over the last column
|
|
115
|
+
css: `
|
|
116
|
+
.bar { --rhp-radius: 2px; --rhp-start-radius: 0px; background: #2c2c31; }
|
|
117
|
+
.goal .bar { background: #d9302a; }
|
|
118
|
+
.past .bar { background: #bfbfba; }
|
|
119
|
+
.past.goal .bar { background: #eda39d; }
|
|
120
|
+
.picked .bar { background: #141416; }
|
|
121
|
+
.picked.goal .bar { background: #b3221d; }
|
|
122
|
+
.count { z-index: 1; --rhp-label-gap: 5px; }
|
|
123
|
+
.count > span { display: block; padding: 2px 5px 1px; border-radius: 3px; background: #141416; color: #fff; font-size: 12px; font-weight: 800; }
|
|
124
|
+
.time { text-align: left; overflow: visible; text-overflow: clip; font-size: 11.5px; font-weight: 700; color: var(--rhp-muted); --rhp-label-gap: 7px; }
|
|
125
|
+
.time > span { display: inline-block; translate: -50% 0; }
|
|
126
|
+
.column:focus-visible { outline: 2px solid #141416; outline-offset: -1px; border-radius: 3px; }
|
|
127
|
+
.say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
128
|
+
@container (max-width: 560px) { .half { display: none; } }
|
|
129
|
+
`,
|
|
130
|
+
}, (d) => html`
|
|
131
|
+
<div class=${() => ["column", d.x1 % GOAL === 0 && "goal", d.index === d.shown && "picked", d.picked != null && d.index > d.picked && "past"].filter(Boolean).join(" ")}
|
|
132
|
+
data-row=${() => d.index}>
|
|
133
|
+
<${Bar} to=${() => d.tally} class="bar" />
|
|
134
|
+
<${Show} when=${() => d.index > 0 && d.x0 % GOAL === 0}>
|
|
135
|
+
<${Label} edge="start" class=${() => (d.x0 % 60 ? "time half" : "time")}><span>${() => clock(d.x0)}</span><//>
|
|
136
|
+
<//>
|
|
137
|
+
<${Show} when=${() => d.index === d.shown}>
|
|
138
|
+
<${Label} at=${() => d.tally} class="count"><span>${() => count.format(d.tally)}</span><//>
|
|
139
|
+
<//>
|
|
140
|
+
<span class="say">${() => `${clock(d.x0)} to ${clock(d.x1 - 1)}: ${count.format(d.tally)} runners`}</span>
|
|
141
|
+
</div>`);
|
|
142
|
+
|
|
143
|
+
// Chart component: the column the reader picks (pointer, tap or arrow keys) feeds the readout and pales the ones after it
|
|
144
|
+
function FinishTimes() {
|
|
145
|
+
const [picked, setPicked] = createSignal(null);
|
|
146
|
+
const shown = () => picked() ?? opening;
|
|
147
|
+
// The row of the slat an element is in, or null (between slats, on the axis or the text)
|
|
148
|
+
const rowOf = (el) => {
|
|
149
|
+
const root = el.closest("[data-row]");
|
|
150
|
+
return root ? +root.dataset.row : null;
|
|
151
|
+
};
|
|
152
|
+
// A mouse or a pen picks as it moves, and so does a finger dragging sideways; a tap or a click picks, the keyboard
|
|
153
|
+
// picks; on no slat the pick stays
|
|
154
|
+
const pick = (e) => {
|
|
155
|
+
const row = rowOf(e.target);
|
|
156
|
+
if (row != null) setPicked(row);
|
|
157
|
+
};
|
|
158
|
+
let start = [0, 0]; // where the last finger went down
|
|
159
|
+
// A finger that moves more sideways than up or down scrubs; one that moves up or down scrolls the page
|
|
160
|
+
const sideways = (e) => Math.abs(e.clientX - start[0]) > Math.abs(e.clientY - start[1]);
|
|
161
|
+
// Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
|
|
162
|
+
const leave = (e) => {
|
|
163
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
164
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
165
|
+
};
|
|
166
|
+
return html`
|
|
167
|
+
<${Poster}
|
|
168
|
+
kicker=${`City marathon · ${count.format(FINISH_TIMES.length)} finishers`}
|
|
169
|
+
title="Runners sprint to beat four hours"
|
|
170
|
+
dek=${html`<span>Runners per 5 minutes of finish time. The <i></i> red columns are the last 5 minutes before a round time: ${count.format(tally[opening])} runners
|
|
171
|
+
finished between ${clock(FOCUS)} and ${clock(FOCUS + BIN - 1)}, and only ${count.format(tally[opening + 1])} in the 5 minutes after.</span>`}
|
|
172
|
+
note="Illustrative data. Point at a column, tap it or drag across the chart, or Tab into it and use the arrow keys."
|
|
173
|
+
onPointerDown=${(e) => (start = [e.clientX, e.clientY])}
|
|
174
|
+
onPointerMove=${(e) => (e.pointerType !== "touch" || sideways(e)) && pick(e)} onClick=${pick} onFocusIn=${pick}
|
|
175
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
176
|
+
<div class="board">
|
|
177
|
+
<div><b>${() => `${clock(x0[shown()])} to ${clock(x1[shown()] - 1)}`}</b><span>Finish time</span></div>
|
|
178
|
+
<div><b>${() => count.format(tally[shown()])}</b><span>Runners</span></div>
|
|
179
|
+
<div><b>${() => percent.format(ahead[shown()] / FINISH_TIMES.length)}</b><span>Finished ahead</span></div>
|
|
180
|
+
</div>
|
|
181
|
+
<p class="axis-name">Runners</p>
|
|
182
|
+
<${Chart} class="chart" orientation="vertical" height=${300} scale=${[0, top.max]} ticks=${top.ticks} format=${(v) => count.format(v)}
|
|
183
|
+
label=${`Histogram of ${count.format(FINISH_TIMES.length)} marathon finish times in 5-minute bins`}
|
|
184
|
+
theme=${{ font: 'Archivo, "Helvetica Neue", Arial, sans-serif', ink: "#141416", muted: "#6a6a70", grid: "#e4e4e0", surface: "#fcfcfa" }}>
|
|
185
|
+
<${Plot} x0=${x0} x1=${x1} tally=${tally} shown=${shown} picked=${picked} keyboard=${true}>${Bin}<//>
|
|
186
|
+
<//>
|
|
187
|
+
<//>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
render(() => html`<${FinishTimes} />`, document.getElementById("chart"));
|
|
191
|
+
</script>
|
|
192
|
+
</body>
|
|
193
|
+
</html>
|