@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,144 @@
|
|
|
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>Europe's power mix</title>
|
|
7
|
+
<meta name="description" content="Stacked bar chart of parts that add up to a total per category, with a key that moves one part to the start of every bar and ranks the bars by it. Use it for totals made of a few parts.">
|
|
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=Oswald:wght@500..700&family=Public+Sans:wght@400..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: #d5d6d2; font-family: "Public Sans", "Segoe UI", system-ui, sans-serif; }
|
|
15
|
+
/* The poster: drafting paper with a faint grid, condensed headline type, a line of pylons under the headline */
|
|
16
|
+
.poster { max-width: 980px; margin: 0 auto; padding: 46px 52px 30px; color: #1b1d21; border-radius: 4px;
|
|
17
|
+
background: linear-gradient(rgb(27 29 33 / .03) 1px, transparent 1px) 0 0 / 100% 24px, linear-gradient(90deg, rgb(27 29 33 / .03) 1px, transparent 1px) 0 0 / 24px 100%, #f4f3ef;
|
|
18
|
+
box-shadow: 0 28px 56px -32px rgb(56 58 54 / .65); }
|
|
19
|
+
.poster figcaption { display: grid; gap: 14px; }
|
|
20
|
+
.poster .kicker { font-size: 12px; font-weight: 800; letter-spacing: .2em; text-transform: uppercase; color: #5a3aa6; }
|
|
21
|
+
.poster .headline { max-width: 17em; font: 600 52px/1.02 Oswald, "Arial Narrow", sans-serif; letter-spacing: -.005em; text-wrap: balance; }
|
|
22
|
+
.poster .dek { max-width: 58ch; font-size: 16px; line-height: 1.55; color: #4a4d53; }
|
|
23
|
+
.poster .note { display: block; margin-top: 22px; font-size: 12px; line-height: 1.5; color: #686b72; }
|
|
24
|
+
.pylons { height: 48px; margin: 22px 0 18px; opacity: .7;
|
|
25
|
+
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='48' fill='none' stroke='%231b1d21' stroke-width='1'%3E%3Cpath d='M4 48 10 14M20 48 14 14M10 14 12 3 14 14M2 10h20M4 18h16M6 38l12-10M18 38 6 28M8 26l8-6M16 26l-8-6M7 47h10'/%3E%3Cpath d='M22 10q90 16 180 0M20 18q92 16 184 0' stroke-width='.8'/%3E%3C/svg%3E") repeat-x 0 0 / 200px 48px; }
|
|
26
|
+
/* The key: one button per source, which also picks it */
|
|
27
|
+
.key { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 18px; }
|
|
28
|
+
.key button { display: inline-flex; align-items: center; gap: 8px; padding: 7px 12px 7px 9px; border: 1.5px solid #d3d2cc; border-radius: 4px; background: #f4f3ef; color: #1b1d21; font: 600 13px/1 "Public Sans", system-ui, sans-serif; cursor: pointer; }
|
|
29
|
+
.key button i { width: 12px; height: 12px; border-radius: 2px; }
|
|
30
|
+
.key button[aria-pressed="true"] { border-color: #1b1d21; background: #1b1d21; color: #f4f3ef; }
|
|
31
|
+
.key button:focus-visible { outline: 2px solid #5a3aa6; outline-offset: 2px; }
|
|
32
|
+
@media (max-width: 600px) {
|
|
33
|
+
body { padding: 16px 16px 32px; }
|
|
34
|
+
.poster { padding: 26px 16px 18px; }
|
|
35
|
+
.poster .headline { font-size: 34px; }
|
|
36
|
+
.poster .dek { font-size: 15px; }
|
|
37
|
+
.pylons { margin: 16px 0 14px; }
|
|
38
|
+
.key button { padding: 6px 10px 6px 8px; }
|
|
39
|
+
}
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<div id="chart"></div>
|
|
44
|
+
<script type="module">
|
|
45
|
+
import { Chart, Plot, Bar, Label, Poster, slat, sortBy, nice, stackUp, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
|
|
46
|
+
|
|
47
|
+
// Data: electricity generated in 2025 by source, in terawatt-hours (TWh) rounded to whole numbers, from Ember's yearly
|
|
48
|
+
// electricity data. Replace with your own.
|
|
49
|
+
const SOURCES = [
|
|
50
|
+
{ key: "coal", name: "Coal", color: "#57493f" },
|
|
51
|
+
{ key: "gas", name: "Gas", color: "#de7a36" },
|
|
52
|
+
{ key: "nuclear", name: "Nuclear", color: "#5a3aa6" },
|
|
53
|
+
{ key: "hydro", name: "Hydro", color: "#4ea6e6" },
|
|
54
|
+
{ key: "wind", name: "Wind", color: "#2f8f55" },
|
|
55
|
+
{ key: "solar", name: "Solar", color: "#e3b400" },
|
|
56
|
+
];
|
|
57
|
+
const COUNTRIES = [
|
|
58
|
+
{ country: "France", coal: 2, gas: 17, nuclear: 392, hydro: 59, wind: 46, solar: 32 },
|
|
59
|
+
{ country: "Germany", coal: 103, gas: 83, nuclear: 0, hydro: 20, wind: 136, solar: 90 },
|
|
60
|
+
{ country: "Spain", coal: 1, gas: 62, nuclear: 54, hydro: 33, wind: 59, solar: 63 },
|
|
61
|
+
{ country: "United Kingdom", coal: 0, gas: 91, nuclear: 36, hydro: 6, wind: 86, solar: 19 },
|
|
62
|
+
{ country: "Italy", coal: 4, gas: 126, nuclear: 0, hydro: 42, wind: 21, solar: 45 },
|
|
63
|
+
{ country: "Poland", coal: 87, gas: 25, nuclear: 0, hydro: 2, wind: 24, solar: 20 },
|
|
64
|
+
{ country: "Norway", coal: 0, gas: 1, nuclear: 0, hydro: 145, wind: 14, solar: 1 },
|
|
65
|
+
{ country: "Sweden", coal: 0, gas: 0, nuclear: 47, hydro: 68, wind: 39, solar: 4 },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const KEYS = SOURCES.map((s) => s.key);
|
|
69
|
+
const twh = new Intl.NumberFormat("en-US");
|
|
70
|
+
const total = (d) => KEYS.reduce((sum, k) => sum + d[k], 0);
|
|
71
|
+
const scale = nice(0, Math.max(...COUNTRIES.map(total)), 5); // 0 to 600 TWh
|
|
72
|
+
|
|
73
|
+
// Slat types: one source's segment, and a country holding its segments in one band (an overlap Plot) and its number.
|
|
74
|
+
// A segment starts 2px after the one before it, and the first starts on the baseline. A segment too thin to show at
|
|
75
|
+
// this width keeps a 2px sliver, drawn over its neighbor, so it shows at every width.
|
|
76
|
+
const thin = (value) => value > 0 && value / scale.max < 0.03;
|
|
77
|
+
const Layer = slat({
|
|
78
|
+
css: `
|
|
79
|
+
.layer { --rhp-radius: 0px; cursor: pointer; }
|
|
80
|
+
.after { --rhp-gap: 2px; }
|
|
81
|
+
.thin { min-width: 2px; z-index: 1; }
|
|
82
|
+
.off { opacity: .22; }
|
|
83
|
+
`,
|
|
84
|
+
}, (s) => html`
|
|
85
|
+
<${Bar} from=${() => s.from} to=${() => s.to} color=${() => s.color} data-source=${() => s.source}
|
|
86
|
+
class=${() => ["layer", s.from > 0 && "after", thin(s.to - s.from) && "thin", s.pick && s.pick !== s.source && "off"].filter(Boolean).join(" ")} />`);
|
|
87
|
+
|
|
88
|
+
const Country = slat({
|
|
89
|
+
thickness: 46,
|
|
90
|
+
inset: "9px",
|
|
91
|
+
room: "auto",
|
|
92
|
+
css: `
|
|
93
|
+
.name { font-size: 15px; font-weight: 700; --rhp-label-gap: 12px; }
|
|
94
|
+
.twh { font-size: 14px; font-weight: 800; font-variant-numeric: tabular-nums; --rhp-label-gap: 10px; }
|
|
95
|
+
.twh small { margin-left: 3px; font-size: 11px; font-weight: 600; color: var(--rhp-muted); }
|
|
96
|
+
@container (max-width: 420px) { .name { max-width: 6.5em; font-size: 13px; line-height: 1.1; white-space: normal; --rhp-label-gap: 8px; } .twh { font-size: 13px; --rhp-label-gap: 6px; } }
|
|
97
|
+
`,
|
|
98
|
+
}, (d) => {
|
|
99
|
+
// The picked source first, so it starts at 0; then the others in the key's order
|
|
100
|
+
const stack = createMemo(() => {
|
|
101
|
+
const keys = d.pick ? [d.pick, ...KEYS.filter((k) => k !== d.pick)] : KEYS;
|
|
102
|
+
const { from, to } = stackUp(keys.map((k) => d[k]));
|
|
103
|
+
return Object.fromEntries(keys.map((k, i) => [k, [from[i], to[i]]]));
|
|
104
|
+
});
|
|
105
|
+
return html`
|
|
106
|
+
<div aria-label=${() => `${d.country}: ` + SOURCES.map((s) => `${s.name.toLowerCase()} ${twh.format(d[s.key])}`).join(", ") + " TWh"}>
|
|
107
|
+
<${Label} edge="start" class="name">${() => d.country}<//>
|
|
108
|
+
<${Plot} overlap=${true} source=${KEYS} color=${SOURCES.map((s) => s.color)} pick=${() => d.pick}
|
|
109
|
+
from=${() => KEYS.map((k) => stack()[k][0])} to=${() => KEYS.map((k) => stack()[k][1])}>${Layer}<//>
|
|
110
|
+
<${Label} edge="end" class="twh">${() => twh.format(d.pick ? d[d.pick] : total(d))}<small>TWh</small><//>
|
|
111
|
+
</div>`;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Chart component: picking a source (in the key or on a bar) moves it to the start of every bar and ranks the countries by it
|
|
115
|
+
function PowerMix() {
|
|
116
|
+
const [pick, setPick] = createSignal(null);
|
|
117
|
+
const choose = (e) => {
|
|
118
|
+
const key = e.target.closest("[data-source]")?.dataset.source;
|
|
119
|
+
if (key) setPick((p) => (p === key ? null : key));
|
|
120
|
+
};
|
|
121
|
+
return html`
|
|
122
|
+
<${Poster}
|
|
123
|
+
kicker="Europe's electricity, by source"
|
|
124
|
+
title="France runs on nuclear, Norway on water and Poland on coal"
|
|
125
|
+
dek="Electricity generated in 2025, in terawatt-hours. Pick a source to line it up at the start of every bar and rank the countries by it; pick it again to go back to the totals."
|
|
126
|
+
note="Source: Ember's yearly electricity data for 2025, rounded to whole terawatt-hours. Bioenergy, oil and other sources are left out."
|
|
127
|
+
onClick=${choose}>
|
|
128
|
+
<div class="pylons" aria-hidden="true"></div>
|
|
129
|
+
<div class="key" role="group" aria-label="Pick a source">
|
|
130
|
+
${SOURCES.map((s) => html`
|
|
131
|
+
<button type="button" data-source=${s.key} aria-pressed=${() => pick() === s.key}><i style=${"background: " + s.color}></i>${s.name}</button>`)}
|
|
132
|
+
</div>
|
|
133
|
+
<${Chart} scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${(v) => twh.format(v)}
|
|
134
|
+
label="Electricity generated by source in eight European countries in 2025, in terawatt-hours"
|
|
135
|
+
theme=${{ font: '"Public Sans", "Segoe UI", system-ui, sans-serif', ink: "#1b1d21", muted: "#686b72", grid: "#d2d0c8" }}>
|
|
136
|
+
<${Plot} rows=${COUNTRIES} key="country" pick=${pick} order=${() => sortBy((d) => (pick() ? d[pick()] : total(d)), "desc")}>${Country}<//>
|
|
137
|
+
<//>
|
|
138
|
+
<//>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
render(() => html`<${PowerMix} />`, document.getElementById("chart"));
|
|
142
|
+
</script>
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|
|
@@ -0,0 +1,215 @@
|
|
|
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>Burger places are hit or miss</title>
|
|
7
|
+
<meta name="description" content="Strip plot of restaurant ratings by cuisine: one dot per restaurant in its cuisine's band (an overlap Plot inside each slat), a median tick, and a readout of the restaurant the reader points at, taps or reaches with the arrow keys. Use it to show every value of a few groups.">
|
|
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=Cabin+Sketch:wght@700&family=Karla:wght@400..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
|
+
:root { color-scheme: light; } /* the page around the chalkboard is light */
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
body { margin: 0; padding: 48px 16px; background: #d8d6d1; font-family: Karla, "Helvetica Neue", Arial, sans-serif; color: #f2f1ec; }
|
|
16
|
+
/* The poster: a chalkboard menu in a wooden frame, its slate smudged where it was wiped */
|
|
17
|
+
.poster { max-width: 940px; margin: 0 auto; padding: 34px 40px 24px; line-height: 1.45; border: 12px solid #6b4a2f; border-radius: 4px;
|
|
18
|
+
background:
|
|
19
|
+
radial-gradient(60% 40% at 22% 18%, rgb(255 255 255 / .05), transparent 70%),
|
|
20
|
+
radial-gradient(50% 35% at 78% 72%, rgb(255 255 255 / .04), transparent 70%),
|
|
21
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='360'%3E%3Cfilter id='f' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='2' seed='5' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 .09 0 0 0 -.035'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E"),
|
|
22
|
+
#2b2e2d;
|
|
23
|
+
box-shadow: inset 0 0 0 1px rgb(0 0 0 / .5), inset 0 0 40px rgb(0 0 0 / .45), 0 0 0 1px #4e341f, 0 22px 44px -18px rgb(0 0 0 / .6); }
|
|
24
|
+
.poster figcaption { display: grid; gap: 8px; margin-bottom: 20px; }
|
|
25
|
+
.poster .kicker { font-size: 13px; font-weight: 700; letter-spacing: .22em; text-transform: uppercase; color: #f3d36b; }
|
|
26
|
+
.poster .headline { font: 700 58px/1.02 "Cabin Sketch", "Comic Sans MS", cursive; letter-spacing: .005em; text-wrap: balance; }
|
|
27
|
+
.poster .dek { max-width: 60ch; font-size: 16px; color: #cfcdc6; }
|
|
28
|
+
.poster .note { display: block; margin-top: 16px; font-size: 12px; color: #a9a7a0; }
|
|
29
|
+
/* The readout: the restaurant under the pointer, two lines tall so the chart below never moves */
|
|
30
|
+
.readout { display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 14px; min-height: 58px; margin: 0 0 14px; padding: 10px 0;
|
|
31
|
+
border-block: 1px dashed rgb(242 241 236 / .35); }
|
|
32
|
+
.readout .stars { font: 700 34px/1 "Cabin Sketch", "Comic Sans MS", cursive; color: #f3d36b; font-variant-numeric: tabular-nums; }
|
|
33
|
+
.readout b { display: block; font-size: 17px; font-weight: 800; }
|
|
34
|
+
.readout span { font-size: 14px; color: #cfcdc6; }
|
|
35
|
+
@media (max-width: 600px) {
|
|
36
|
+
body { padding: 16px 12px; }
|
|
37
|
+
.poster { padding: 24px 16px 18px; border-width: 9px; }
|
|
38
|
+
.poster .kicker { font-size: 11px; letter-spacing: .16em; }
|
|
39
|
+
.poster .headline { font-size: 38px; }
|
|
40
|
+
.poster .dek { font-size: 14.5px; }
|
|
41
|
+
.readout { min-height: 64px; }
|
|
42
|
+
.readout .stars { font-size: 28px; }
|
|
43
|
+
.readout b { font-size: 15.5px; }
|
|
44
|
+
.readout span { font-size: 13px; }
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<div id="chart"></div>
|
|
50
|
+
<script type="module">
|
|
51
|
+
import { Chart, Plot, Dot, Tick, Label, Poster, slat, summary, sortBy, every, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
52
|
+
|
|
53
|
+
// Data: invented restaurants in one city, their cuisine, average rating out of 5 and number of reviews. Illustrative.
|
|
54
|
+
// Replace with your own.
|
|
55
|
+
const PLACES = [
|
|
56
|
+
{ name: "Blue Nile Kitchen", cuisine: "Ethiopian", rating: 4.7, reviews: 412 },
|
|
57
|
+
{ name: "Lalibela Table", cuisine: "Ethiopian", rating: 4.6, reviews: 288 },
|
|
58
|
+
{ name: "Addis Café", cuisine: "Ethiopian", rating: 4.4, reviews: 156 },
|
|
59
|
+
{ name: "Injera House", cuisine: "Ethiopian", rating: 4.8, reviews: 521 },
|
|
60
|
+
{ name: "Habesha Corner", cuisine: "Ethiopian", rating: 4.5, reviews: 203 },
|
|
61
|
+
{ name: "Teff & Tea", cuisine: "Ethiopian", rating: 4.3, reviews: 97 },
|
|
62
|
+
{ name: "Kumo Ramen", cuisine: "Japanese", rating: 4.8, reviews: 1240 },
|
|
63
|
+
{ name: "Tanuki Sushi Bar", cuisine: "Japanese", rating: 4.6, reviews: 880 },
|
|
64
|
+
{ name: "Yuzu Izakaya", cuisine: "Japanese", rating: 4.4, reviews: 610 },
|
|
65
|
+
{ name: "Hinode Bento", cuisine: "Japanese", rating: 4.1, reviews: 325 },
|
|
66
|
+
{ name: "Sakura Garden", cuisine: "Japanese", rating: 3.9, reviews: 402 },
|
|
67
|
+
{ name: "Mochi Mochi", cuisine: "Japanese", rating: 4.5, reviews: 290 },
|
|
68
|
+
{ name: "Shiro Udon", cuisine: "Japanese", rating: 4.3, reviews: 515 },
|
|
69
|
+
{ name: "Oni Grill", cuisine: "Japanese", rating: 4.0, reviews: 188 },
|
|
70
|
+
{ name: "Kaiseki Mori", cuisine: "Japanese", rating: 4.7, reviews: 264 },
|
|
71
|
+
{ name: "Tokyo Express", cuisine: "Japanese", rating: 3.6, reviews: 733 },
|
|
72
|
+
{ name: "Saffron Lane", cuisine: "Indian", rating: 4.6, reviews: 970 },
|
|
73
|
+
{ name: "Masala Theory", cuisine: "Indian", rating: 4.4, reviews: 540 },
|
|
74
|
+
{ name: "Chai Wallah", cuisine: "Indian", rating: 4.2, reviews: 311 },
|
|
75
|
+
{ name: "Tandoor Nights", cuisine: "Indian", rating: 4.0, reviews: 466 },
|
|
76
|
+
{ name: "Bombay Tiffin", cuisine: "Indian", rating: 4.5, reviews: 288 },
|
|
77
|
+
{ name: "Curry Leaf", cuisine: "Indian", rating: 4.3, reviews: 602 },
|
|
78
|
+
{ name: "Delhi Darbar", cuisine: "Indian", rating: 3.7, reviews: 380 },
|
|
79
|
+
{ name: "Goa Shack", cuisine: "Indian", rating: 4.1, reviews: 215 },
|
|
80
|
+
{ name: "Spice Route", cuisine: "Indian", rating: 3.5, reviews: 450 },
|
|
81
|
+
{ name: "La Paloma", cuisine: "Mexican", rating: 4.5, reviews: 810 },
|
|
82
|
+
{ name: "Taquería El Sol", cuisine: "Mexican", rating: 4.7, reviews: 1055 },
|
|
83
|
+
{ name: "Casa Verde", cuisine: "Mexican", rating: 4.2, reviews: 390 },
|
|
84
|
+
{ name: "Mole Mole", cuisine: "Mexican", rating: 4.0, reviews: 245 },
|
|
85
|
+
{ name: "Burrito Barn", cuisine: "Mexican", rating: 3.4, reviews: 930 },
|
|
86
|
+
{ name: "Agave Azul", cuisine: "Mexican", rating: 4.3, reviews: 520 },
|
|
87
|
+
{ name: "El Comal", cuisine: "Mexican", rating: 4.6, reviews: 342 },
|
|
88
|
+
{ name: "Tortilla Flats", cuisine: "Mexican", rating: 3.8, reviews: 611 },
|
|
89
|
+
{ name: "Salsa Brava", cuisine: "Mexican", rating: 3.9, reviews: 288 },
|
|
90
|
+
{ name: "Cantina 52", cuisine: "Mexican", rating: 3.2, reviews: 474 },
|
|
91
|
+
{ name: "Trattoria Nonna", cuisine: "Italian", rating: 4.6, reviews: 1320 },
|
|
92
|
+
{ name: "Pizzeria Bella", cuisine: "Italian", rating: 4.3, reviews: 2010 },
|
|
93
|
+
{ name: "Osteria Verde", cuisine: "Italian", rating: 4.4, reviews: 540 },
|
|
94
|
+
{ name: "Pasta Fresca", cuisine: "Italian", rating: 4.1, reviews: 690 },
|
|
95
|
+
{ name: "Il Forno", cuisine: "Italian", rating: 3.9, reviews: 820 },
|
|
96
|
+
{ name: "Gelato & Co", cuisine: "Italian", rating: 4.7, reviews: 310 },
|
|
97
|
+
{ name: "Luigi's Slice", cuisine: "Italian", rating: 3.5, reviews: 1150 },
|
|
98
|
+
{ name: "Bistro Roma", cuisine: "Italian", rating: 3.7, reviews: 402 },
|
|
99
|
+
{ name: "Vino e Cucina", cuisine: "Italian", rating: 4.2, reviews: 288 },
|
|
100
|
+
{ name: "Mamma Mia Express", cuisine: "Italian", rating: 3.1, reviews: 960 },
|
|
101
|
+
{ name: "Patty Shack", cuisine: "Burgers", rating: 4.8, reviews: 1420 },
|
|
102
|
+
{ name: "Smash Bros Burgers", cuisine: "Burgers", rating: 4.4, reviews: 980 },
|
|
103
|
+
{ name: "Grill Republic", cuisine: "Burgers", rating: 3.9, reviews: 640 },
|
|
104
|
+
{ name: "Bun Run", cuisine: "Burgers", rating: 3.3, reviews: 720 },
|
|
105
|
+
{ name: "The Beef Joint", cuisine: "Burgers", rating: 2.9, reviews: 530 },
|
|
106
|
+
{ name: "Double Deck", cuisine: "Burgers", rating: 3.6, reviews: 810 },
|
|
107
|
+
{ name: "Burger Barn", cuisine: "Burgers", rating: 2.6, reviews: 1090 },
|
|
108
|
+
{ name: "Stack & Sauce", cuisine: "Burgers", rating: 4.1, reviews: 450 },
|
|
109
|
+
{ name: "Drive Thru Deluxe", cuisine: "Burgers", rating: 3.0, reviews: 2300 },
|
|
110
|
+
];
|
|
111
|
+
const FOCUS = "Patty Shack"; // the restaurant the readout opens on
|
|
112
|
+
|
|
113
|
+
// Each dot takes the first of a few lanes across its band that has no dot with a close rating, so ties never hide each other
|
|
114
|
+
const LANES = [0.5, 0.24, 0.76, 0.08, 0.92];
|
|
115
|
+
const lanes = (ratings) => {
|
|
116
|
+
const taken = LANES.map(() => []);
|
|
117
|
+
return ratings.map((r) => {
|
|
118
|
+
const k = Math.max(0, LANES.findIndex((_, i) => taken[i].every((q) => Math.abs(q - r) >= 0.15)));
|
|
119
|
+
taken[k].push(r);
|
|
120
|
+
return LANES[k];
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
// One row per cuisine: its restaurants from the lowest rating up (each with its index in PLACES and its lane), and their median
|
|
124
|
+
const CUISINES = [...new Set(PLACES.map((p) => p.cuisine))].map((cuisine) => {
|
|
125
|
+
const places = PLACES.filter((p) => p.cuisine === cuisine).sort((a, b) => a.rating - b.rating);
|
|
126
|
+
const across = lanes(places.map((p) => p.rating));
|
|
127
|
+
return {
|
|
128
|
+
cuisine,
|
|
129
|
+
places: places.map((p, i) => ({ ...p, id: PLACES.indexOf(p), across: across[i] })),
|
|
130
|
+
median: summary(places.map((p) => p.rating)).median,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
const opening = Math.max(0, PLACES.findIndex((p) => p.name === FOCUS));
|
|
134
|
+
const lowest = Math.floor(Math.min(...PLACES.map((p) => p.rating)) * 2) / 2; // the scale starts on the half star below the lowest rating
|
|
135
|
+
const one = new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
136
|
+
const count = new Intl.NumberFormat("en-US");
|
|
137
|
+
|
|
138
|
+
// Slat types: a restaurant is a chalk dot at its rating; a cuisine is a band of dots with a line at its median
|
|
139
|
+
const Place = slat({
|
|
140
|
+
css: `
|
|
141
|
+
.place { width: 13px; height: 13px; background: #f2f1ec; box-shadow: 0 0 0 2px var(--rhp-surface); cursor: pointer; }
|
|
142
|
+
.place::before { content: ""; position: absolute; inset: -6px; border-radius: 50%; } /* a bigger target for a finger */
|
|
143
|
+
.place.on { z-index: 1; width: 19px; height: 19px; background: #f3d36b; box-shadow: 0 0 0 2px var(--rhp-surface), 0 0 0 4px #f3d36b; }
|
|
144
|
+
.place:focus-visible { outline: 2px solid #f3d36b; outline-offset: 4px; }
|
|
145
|
+
.say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
146
|
+
`,
|
|
147
|
+
}, (p) => html`
|
|
148
|
+
<${Dot} at=${() => p.rating} across=${() => p.across} class=${() => (p.picked === p.id ? "place on" : "place")} data-place=${() => p.id}>
|
|
149
|
+
<span class="say">${() => `${p.name}, ${one.format(p.rating)} stars from ${count.format(p.reviews)} reviews`}</span>
|
|
150
|
+
<//>`);
|
|
151
|
+
|
|
152
|
+
const Cuisine = slat({
|
|
153
|
+
thickness: 62,
|
|
154
|
+
room: { start: "auto", end: 16 },
|
|
155
|
+
css: `
|
|
156
|
+
.name { font-size: 16px; font-weight: 800; line-height: 1.2; --rhp-label-gap: 16px; }
|
|
157
|
+
.name small { display: block; font-size: 12.5px; font-weight: 600; color: var(--rhp-muted); }
|
|
158
|
+
.median { --rhp-tick-width: 3px; border-radius: 2px; background: rgb(242 241 236 / .55); }
|
|
159
|
+
.cuisine { border-top: 1px dashed rgb(242 241 236 / .16); }
|
|
160
|
+
@container (max-width: 480px) { .name { font-size: 14px; --rhp-label-gap: 12px; } .name small { font-size: 11.5px; } }
|
|
161
|
+
`,
|
|
162
|
+
}, (d) => html`
|
|
163
|
+
<div class="cuisine">
|
|
164
|
+
<${Label} edge="start" class="name">${() => d.cuisine}<small>${() => `median ${one.format(d.median)}`}</small><//>
|
|
165
|
+
<${Tick} at=${() => d.median} thick=${0.92} class="median" />
|
|
166
|
+
<${Plot} overlap=${true} rows=${() => d.places} picked=${() => d.picked} keyboard=${true}>${Place}<//>
|
|
167
|
+
</div>`);
|
|
168
|
+
|
|
169
|
+
// Chart component: the restaurant the reader points at, taps or reaches with the arrow keys is lit and read out
|
|
170
|
+
function RestaurantRatings() {
|
|
171
|
+
const [picked, setPicked] = createSignal(null);
|
|
172
|
+
const shown = () => picked() ?? opening;
|
|
173
|
+
const place = () => PLACES[shown()];
|
|
174
|
+
// The row of the slat an element is in, or null (between slats, on the axis or the text)
|
|
175
|
+
const rowOf = (el) => {
|
|
176
|
+
const root = el.closest("[data-place]");
|
|
177
|
+
return root ? +root.dataset.place : null;
|
|
178
|
+
};
|
|
179
|
+
// A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
|
|
180
|
+
const pick = (e) => {
|
|
181
|
+
const row = rowOf(e.target);
|
|
182
|
+
if (row != null) setPicked(row);
|
|
183
|
+
};
|
|
184
|
+
// Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
|
|
185
|
+
const leave = (e) => {
|
|
186
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
187
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
188
|
+
};
|
|
189
|
+
return html`
|
|
190
|
+
<${Poster}
|
|
191
|
+
kicker="City food guide · ratings out of 5"
|
|
192
|
+
title="Burger places are hit or miss"
|
|
193
|
+
dek="Every dot is a restaurant, placed by its average rating. Ethiopian kitchens all score between 4.3 and 4.8; burgers run from under 3 to almost 5."
|
|
194
|
+
note="Illustrative data for invented restaurants. Point at a dot or tap it for the restaurant, or Tab into a cuisine and use the arrow keys."
|
|
195
|
+
onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
|
|
196
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
197
|
+
<div class="readout">
|
|
198
|
+
<span class="stars">${() => one.format(place().rating)} ★</span>
|
|
199
|
+
<div>
|
|
200
|
+
<b>${() => place().name}</b>
|
|
201
|
+
<span>${() => `${place().cuisine} · ${count.format(place().reviews)} reviews`}</span>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<${Chart} scale=${[lowest, 5]} ticks=${every(0.5)} format=${(v) => `${one.format(v)} ★`}
|
|
205
|
+
label="Strip plot of restaurant ratings out of 5, one dot per restaurant, by cuisine"
|
|
206
|
+
theme=${{ font: 'Karla, "Helvetica Neue", Arial, sans-serif', ink: "#f2f1ec", muted: "#b3b1aa", grid: "rgb(242 241 236 / .16)", surface: "#2b2e2d" }}>
|
|
207
|
+
<${Plot} rows=${CUISINES} order=${sortBy("median", "desc")} picked=${shown}>${Cuisine}<//>
|
|
208
|
+
<//>
|
|
209
|
+
<//>`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
render(() => html`<${RestaurantRatings} />`, document.getElementById("chart"));
|
|
213
|
+
</script>
|
|
214
|
+
</body>
|
|
215
|
+
</html>
|
|
@@ -0,0 +1,163 @@
|
|
|
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>Teenagers sleep longer at weekends</title>
|
|
7
|
+
<meta name="description" content="Violin plot of hours of sleep by age group made with density(): a mirrored Area per group with its median, over a band of the recommended range, and a switch between weeknights and weekends that morphs the shapes. Use it to compare the full distribution of several groups.">
|
|
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=Newsreader:ital,opsz,wght@1,6..72,400..600&family=Albert+Sans:wght@400..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
|
+
:root { color-scheme: dark; }
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
body { margin: 0; padding: 48px 16px; background: #050506; font-family: "Albert Sans", "Helvetica Neue", Arial, sans-serif; color: #f1eee6; }
|
|
16
|
+
/* The poster: a night sky with a scatter of faint stars and a crescent moon */
|
|
17
|
+
.poster { position: relative; max-width: 900px; margin: 0 auto; padding: 40px 48px 28px; border-radius: 20px; line-height: 1.45;
|
|
18
|
+
background:
|
|
19
|
+
radial-gradient(1px 1px at 23px 41px, rgb(255 255 255 / .55), transparent),
|
|
20
|
+
radial-gradient(1px 1px at 131px 97px, rgb(255 255 255 / .4), transparent),
|
|
21
|
+
radial-gradient(1.5px 1.5px at 202px 19px, rgb(255 255 255 / .5), transparent),
|
|
22
|
+
radial-gradient(1px 1px at 77px 173px, rgb(255 255 255 / .35), transparent),
|
|
23
|
+
radial-gradient(1px 1px at 251px 151px, rgb(255 255 255 / .45), transparent),
|
|
24
|
+
#101115;
|
|
25
|
+
background-size: 270px 210px, 270px 210px, 270px 210px, 270px 210px, 270px 210px, auto;
|
|
26
|
+
box-shadow: 0 0 0 1px rgb(255 255 255 / .06); }
|
|
27
|
+
.poster::before { content: ""; position: absolute; top: 34px; right: 44px; width: 58px; height: 58px; border-radius: 50%; box-shadow: 15px -9px 0 0 #efe4c4; }
|
|
28
|
+
.poster figcaption { display: grid; gap: 10px; margin-bottom: 22px; padding-right: 90px; }
|
|
29
|
+
.poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; color: #a8a7ad; }
|
|
30
|
+
.poster .headline { font: italic 500 62px/1 Newsreader, Georgia, serif; letter-spacing: -.015em; text-wrap: balance; }
|
|
31
|
+
.poster .dek { max-width: 58ch; font-size: 16px; color: #c6c4cb; }
|
|
32
|
+
.poster .note { display: block; margin-top: 18px; font-size: 12px; color: #9a99a0; }
|
|
33
|
+
/* The switch between the two kinds of night, with the band's key beside it */
|
|
34
|
+
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 22px; margin: 0 0 18px; }
|
|
35
|
+
.switch { display: inline-flex; padding: 3px; border-radius: 99px; background: #1d1e24; box-shadow: inset 0 0 0 1px #2e2f37; }
|
|
36
|
+
.switch button { display: inline-flex; align-items: center; gap: 8px; padding: 7px 16px; border: 0; border-radius: 99px; background: none; color: #b4b3ba;
|
|
37
|
+
font: 700 14px/1 "Albert Sans", "Helvetica Neue", Arial, sans-serif; cursor: pointer; }
|
|
38
|
+
.switch button i { width: 10px; height: 10px; border-radius: 50%; }
|
|
39
|
+
.switch button[aria-pressed="true"] { background: #f1eee6; color: #101115; }
|
|
40
|
+
.switch button:focus-visible { outline: 2px solid #f1eee6; outline-offset: 2px; }
|
|
41
|
+
.keys { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 13px; color: #a8a7ad; }
|
|
42
|
+
.keys span { display: inline-flex; align-items: center; gap: 8px; }
|
|
43
|
+
.keys .band { width: 22px; height: 12px; border-radius: 2px; background: rgb(255 255 255 / .05); box-shadow: inset 0 1px 0 rgb(255 255 255 / .38), inset 0 -1px 0 rgb(255 255 255 / .38); }
|
|
44
|
+
.keys .median { position: relative; width: 22px; height: 12px; border-radius: 6px; background: #5b5c66; }
|
|
45
|
+
.keys .median::after { content: ""; position: absolute; left: 5px; right: 5px; top: 5px; height: 2px; background: #101115; }
|
|
46
|
+
@media (max-width: 600px) {
|
|
47
|
+
body { padding: 16px; }
|
|
48
|
+
.poster { padding: 28px 18px 20px; border-radius: 16px; }
|
|
49
|
+
.poster::before { top: 22px; right: 22px; width: 38px; height: 38px; box-shadow: 10px -6px 0 0 #efe4c4; }
|
|
50
|
+
.poster figcaption { padding-right: 52px; margin-bottom: 18px; }
|
|
51
|
+
.poster .kicker { font-size: 11px; letter-spacing: .14em; }
|
|
52
|
+
.poster .headline { font-size: 40px; }
|
|
53
|
+
.poster .dek { font-size: 14.5px; }
|
|
54
|
+
.switch button { padding: 7px 13px; font-size: 13px; }
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<div id="chart"></div>
|
|
60
|
+
<script type="module">
|
|
61
|
+
import { Chart, Plot, Bar, Tick, Label, Area, Poster, slat, density, summary, extent, html, render, createSignal, createMemo } from "@bezda/rhp/standalone";
|
|
62
|
+
|
|
63
|
+
// Data: hours slept per night from a week of sleep diaries, one number per person, by age group. Illustrative. Replace with your own.
|
|
64
|
+
const SLEEP = {
|
|
65
|
+
weeknights: {
|
|
66
|
+
"14-17": [7.75, 6.5, 7.25, 6, 7, 7.75, 6.5, 7.25, 5.75, 8, 6.75, 7.5, 6.25, 7, 4.75, 7.75, 6.5, 7.25, 6, 8.5, 6.75, 7.5, 6.25, 7, 5.5, 7.75, 6.5, 7.25, 6, 6.75, 7.5, 6.25, 7, 5.75, 8, 6.75, 7.5, 6.25, 7, 5],
|
|
67
|
+
"18-25": [8, 6.75, 7.5, 6, 7, 8, 6.5, 7.5, 5.75, 8.5, 7, 7.75, 6.5, 7.25, 4.75, 8.25, 6.75, 7.5, 6, 9, 7, 7.75, 6.5, 7.25, 5.5, 8.25, 6.75, 7.5, 6, 7, 7.75, 6.5, 7.25, 5.75, 8.5, 6.75, 7.75, 6.25, 7.25, 5.25],
|
|
68
|
+
"26-44": [7.75, 6.5, 7.25, 6, 7, 7.5, 6.5, 7.25, 5.75, 8, 6.75, 7.5, 6.25, 7, 4.75, 7.75, 6.75, 7.25, 6, 8.25, 6.75, 7.5, 6.25, 7, 5.5, 7.75, 6.5, 7.25, 6, 6.75, 7.5, 6.5, 7, 5.75, 8, 6.75, 7.25, 6.25, 7, 5.25],
|
|
69
|
+
"45-64": [7.75, 6.5, 7.25, 6, 6.75, 7.5, 6.25, 7, 5.5, 8, 6.75, 7.5, 6.25, 7, 4.5, 7.75, 6.5, 7.25, 5.75, 8.25, 6.75, 7.5, 6.25, 7, 5.25, 7.75, 6.5, 7.25, 6, 6.75, 7.5, 6.25, 7, 5.5, 8, 6.5, 7.25, 6, 7, 5],
|
|
70
|
+
"65+": [8.25, 7, 7.75, 6.25, 7.25, 8, 6.5, 7.5, 5.75, 8.5, 7, 7.75, 6.25, 7.25, 4.5, 8, 6.75, 7.5, 6, 9, 7, 7.75, 6.5, 7.25, 5.5, 8.25, 6.75, 7.75, 6.25, 7.25, 8, 6.75, 7.5, 6, 8.75, 7, 8, 6.5, 7.5, 5.25],
|
|
71
|
+
},
|
|
72
|
+
weekends: {
|
|
73
|
+
"14-17": [8.75, 9.5, 8.25, 9, 10, 8.5, 9.25, 8, 11, 8.75, 9.75, 8.5, 9.25, 7.75, 10.25, 8.75, 9.5, 8.25, 9, 10, 8.5, 9.25, 7.75, 10.5, 8.75, 9.5, 8.25, 9, 7.25, 10, 8.5, 9.5, 8, 11.5, 9, 10, 8.5, 9.25, 7.75, 10.5],
|
|
74
|
+
"18-25": [8.25, 9, 7.5, 8.5, 9.5, 8, 8.75, 7.25, 10.5, 8.25, 9.25, 7.75, 8.75, 7, 10, 8.25, 9, 7.5, 8.5, 9.5, 8, 8.75, 7.25, 10.25, 8.25, 9, 7.5, 8.5, 6.5, 9.75, 8, 9, 7.5, 11.25, 8.5, 9.5, 8, 8.75, 7, 10],
|
|
75
|
+
"26-44": [7.5, 8.25, 7, 7.75, 8.75, 7.25, 8, 6.75, 9.5, 7.75, 8.5, 7.25, 8, 6.5, 9, 7.5, 8.25, 7, 7.75, 8.75, 7.25, 8, 6.75, 9.25, 7.5, 8.25, 7, 7.75, 6, 8.75, 7.5, 8.25, 7, 10.25, 7.75, 8.75, 7.25, 8, 6.75, 9.25],
|
|
76
|
+
"45-64": [7, 7.75, 6.5, 7.25, 8, 6.75, 7.5, 6.25, 9, 7.25, 8, 6.75, 7.5, 6, 8.5, 7, 7.75, 6.5, 7.25, 8, 6.75, 7.5, 6.25, 8.75, 7, 7.75, 6.5, 7.25, 5.75, 8.25, 7, 7.75, 6.5, 9.75, 7.25, 8.25, 7, 7.5, 6.25, 8.75],
|
|
77
|
+
"65+": [7, 7.75, 6.5, 7.25, 8.5, 7, 7.75, 6.25, 9.5, 7.25, 8.25, 6.75, 7.5, 5.75, 8.75, 7, 7.75, 6.25, 7.25, 8.25, 6.75, 7.5, 6, 9, 7.25, 8, 6.75, 7.5, 5.5, 8.5, 7, 7.75, 6.25, 10, 7.25, 8.25, 6.75, 7.5, 5.75, 8.75],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
// The sleep the National Sleep Foundation recommends for each age, in hours (2015)
|
|
81
|
+
const RECOMMENDED = { "14-17": [8, 10], "18-25": [7, 9], "26-44": [7, 9], "45-64": [7, 9], "65+": [7, 8] };
|
|
82
|
+
const NIGHTS = [
|
|
83
|
+
{ key: "weeknights", name: "Weeknights", color: "#7489ee" },
|
|
84
|
+
{ key: "weekends", name: "Weekends", color: "#c98224" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const AGES = Object.keys(RECOMMENDED);
|
|
88
|
+
const SCALE = [4, 12]; // hours shown
|
|
89
|
+
// A group's outline: density() a little past its shortest and longest night so the tips taper, kept inside the scale
|
|
90
|
+
const outline = (hours) => {
|
|
91
|
+
const [lo, hi] = extent(hours);
|
|
92
|
+
return density(hours, { points: 48, domain: [Math.max(SCALE[0], lo - 1), Math.min(SCALE[1], hi + 1)] });
|
|
93
|
+
};
|
|
94
|
+
// One peak for every shape, both kinds of night, so widths compare across groups and the switch never rescales them
|
|
95
|
+
const PEAK = Math.max(...NIGHTS.flatMap((n) => AGES.flatMap((a) => outline(SLEEP[n.key][a]).map((p) => p[1]))));
|
|
96
|
+
const share = new Intl.NumberFormat("en-US", { style: "percent" });
|
|
97
|
+
const teensShort = SLEEP.weeknights["14-17"].filter((h) => h < RECOMMENDED["14-17"][0]).length / SLEEP.weeknights["14-17"].length;
|
|
98
|
+
const clock = (hours) => {
|
|
99
|
+
const minutes = Math.round(hours * 60);
|
|
100
|
+
return `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, "0")}`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Slat type: an age group; the recommended band, the shape of its nights (density, mirrored) and a line at the median
|
|
104
|
+
const AgeGroup = slat({
|
|
105
|
+
inset: 0.06,
|
|
106
|
+
room: { start: 48, end: 8 },
|
|
107
|
+
css: `
|
|
108
|
+
.age { --rhp-length-time: .6s; --rhp-length-ease: cubic-bezier(.3, .7, .2, 1); }
|
|
109
|
+
.band { --rhp-radius: 2px; background: rgb(255 255 255 / .04); box-shadow: inset 0 1px 0 rgb(255 255 255 / .34), inset 0 -1px 0 rgb(255 255 255 / .34); }
|
|
110
|
+
.shape { fill: var(--rhp-color); stroke: none; }
|
|
111
|
+
@media (prefers-reduced-motion: no-preference) { .shape path { transition-property: d, fill; } } /* the color fades as the outline moves */
|
|
112
|
+
.median { --rhp-tick-width: 2px; background: #101115; }
|
|
113
|
+
.name { font-size: 15px; font-weight: 800; color: var(--rhp-ink); --rhp-label-gap: 10px; }
|
|
114
|
+
.name small { display: block; margin-top: 3px; font-size: 12px; font-weight: 600; color: var(--rhp-muted); font-variant-numeric: tabular-nums; }
|
|
115
|
+
@container (max-width: 420px) { .name { font-size: 13px; } .name small { font-size: 11px; } }
|
|
116
|
+
.say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
117
|
+
`,
|
|
118
|
+
}, (d) => {
|
|
119
|
+
const shape = createMemo(() => outline(d.hours));
|
|
120
|
+
const median = createMemo(() => summary(d.hours).median);
|
|
121
|
+
return html`
|
|
122
|
+
<div class="age">
|
|
123
|
+
<${Bar} from=${() => d.low} to=${() => d.high} thick=${0.92} class="band" />
|
|
124
|
+
<${Area} points=${shape} mirror=${true} smooth=${true} peak=${() => d.peak} color=${() => d.color} class="shape" />
|
|
125
|
+
<${Tick} at=${median} thick=${0.36} class="median" />
|
|
126
|
+
<${Label} edge="start" class="name">${() => d.age}<small>${() => clock(median())}</small><//>
|
|
127
|
+
<span class="say">${() => `: median night ${clock(median())}, ${d.low} to ${d.high} hours recommended`}</span>
|
|
128
|
+
</div>`;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Chart component: the switch picks which nights the shapes show; they morph from one to the other
|
|
132
|
+
function SleepByAge() {
|
|
133
|
+
const [when, setWhen] = createSignal("weeknights");
|
|
134
|
+
const night = () => NIGHTS.find((n) => n.key === when());
|
|
135
|
+
return html`
|
|
136
|
+
<${Poster}
|
|
137
|
+
kicker="Sleep diaries · hours per night"
|
|
138
|
+
title="Teenagers sleep two hours longer at weekends"
|
|
139
|
+
dek=${`Each shape is wide where many people in an age group slept that long. The band is the sleep recommended for the age: on weeknights, ${share.format(teensShort)} of teenagers got less than 8 hours.`}
|
|
140
|
+
note="Sleep diaries: illustrative data, 40 people per age group. Recommended hours: National Sleep Foundation, 2015. Use the switch to compare weeknights with weekends.">
|
|
141
|
+
<div class="controls">
|
|
142
|
+
<div class="switch" role="group" aria-label="Nights shown">
|
|
143
|
+
${NIGHTS.map((n) => html`
|
|
144
|
+
<button type="button" aria-pressed=${() => String(when() === n.key)} onClick=${() => setWhen(n.key)}><i style=${{ background: n.color }}></i>${n.name}</button>`)}
|
|
145
|
+
</div>
|
|
146
|
+
<div class="keys">
|
|
147
|
+
<span><i class="band"></i>Recommended for the age</span>
|
|
148
|
+
<span><i class="median"></i>Median night</span>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
<${Chart} orientation="vertical" height=${340} scale=${SCALE} ticks=${[4, 6, 8, 10, 12]} format=${(v) => `${v} h`}
|
|
152
|
+
label=${() => `Violin plot of hours of sleep on ${night().name.toLowerCase()} by age group`}
|
|
153
|
+
theme=${{ font: '"Albert Sans", "Helvetica Neue", Arial, sans-serif', ink: "#f1eee6", muted: "#9a99a0", grid: "#24252b", surface: "#101115" }}>
|
|
154
|
+
<${Plot} age=${AGES} hours=${() => AGES.map((a) => SLEEP[when()][a])} low=${AGES.map((a) => RECOMMENDED[a][0])} high=${AGES.map((a) => RECOMMENDED[a][1])}
|
|
155
|
+
color=${() => night().color} peak=${PEAK}>${AgeGroup}<//>
|
|
156
|
+
<//>
|
|
157
|
+
<//>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
render(() => html`<${SleepByAge} />`, document.getElementById("chart"));
|
|
161
|
+
</script>
|
|
162
|
+
</body>
|
|
163
|
+
</html>
|