@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -2
  3. package/bin/rhp-mcp.js +125 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +363 -0
  6. package/skill/recipes/area.html +150 -0
  7. package/skill/recipes/bar.html +139 -0
  8. package/skill/recipes/box-plot.html +158 -0
  9. package/skill/recipes/bubble.html +182 -0
  10. package/skill/recipes/bullet.html +132 -0
  11. package/skill/recipes/candlestick.html +173 -0
  12. package/skill/recipes/column.html +125 -0
  13. package/skill/recipes/diverging-bars.html +144 -0
  14. package/skill/recipes/donut.html +225 -0
  15. package/skill/recipes/dumbbell.html +131 -0
  16. package/skill/recipes/gantt.html +186 -0
  17. package/skill/recipes/grouped-bars.html +136 -0
  18. package/skill/recipes/heatmap.html +177 -0
  19. package/skill/recipes/histogram.html +193 -0
  20. package/skill/recipes/line.html +164 -0
  21. package/skill/recipes/live.html +249 -0
  22. package/skill/recipes/lollipop.html +146 -0
  23. package/skill/recipes/multi-line.html +223 -0
  24. package/skill/recipes/pyramid.html +156 -0
  25. package/skill/recipes/race.html +272 -0
  26. package/skill/recipes/radial-bars.html +151 -0
  27. package/skill/recipes/scatter.html +193 -0
  28. package/skill/recipes/slope.html +193 -0
  29. package/skill/recipes/sparklines.html +169 -0
  30. package/skill/recipes/stacked-100.html +152 -0
  31. package/skill/recipes/stacked-bars.html +144 -0
  32. package/skill/recipes/strip.html +215 -0
  33. package/skill/recipes/violin.html +163 -0
  34. package/skill/recipes/waffle.html +159 -0
  35. package/skill/recipes/waterfall.html +121 -0
  36. package/skill/references/api.md +1556 -0
  37. package/skill/references/design.md +763 -0
  38. package/skill/references/environments.md +1570 -0
  39. package/skill/references/forms.md +160 -0
  40. package/skill/references/interaction.md +792 -0
  41. package/skill/references/pitfalls.md +157 -0
  42. package/src/check/README.md +163 -0
  43. package/src/check/browser.js +92 -0
  44. package/src/check/build.js +439 -0
  45. package/src/check/colors.js +324 -0
  46. package/src/check/findings.js +247 -0
  47. package/src/check/index.js +419 -0
  48. package/src/check/inpage.js +1268 -0
  49. package/src/check/interact.js +266 -0
  50. package/src/check/png.js +98 -0
  51. package/src/check/report.js +103 -0
  52. package/src/check/source.js +387 -0
  53. package/src/check/sourcemap.js +75 -0
  54. package/src/guard/note.js +30 -0
  55. package/src/guard/render.js +17 -0
  56. package/src/guard/rhp.js +608 -0
  57. package/src/guard/rules.js +125 -0
  58. package/src/server.js +265 -0
  59. package/src/skill.js +140 -0
@@ -0,0 +1,139 @@
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>The most spoken languages</title>
7
+ <meta name="description" content="Horizontal bar chart, ranked, the top bar highlighted, with a readout on hover, focus and tap. Use it to rank categories by one value, especially with long names.">
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=Instrument+Sans:wght@400..700&family=Instrument+Serif:ital@0;1&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: #d8d4cc; font-family: "Instrument Sans", "Helvetica Neue", Arial, sans-serif; }
15
+ /* The poster: warm paper with a fine grain, black type, one vermilion accent */
16
+ .poster { display: grid; grid-template-columns: minmax(0, 1fr) 230px; column-gap: 48px; max-width: 940px; margin: 0 auto; padding: 52px 56px 32px; color: #161412; border-radius: 3px;
17
+ background: #f4f1ea url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .09 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
18
+ box-shadow: 0 1px 2px rgb(60 55 48 / .2), 0 28px 50px -28px rgb(60 55 48 / .55); }
19
+ .poster > * { grid-column: 1 / -1; min-width: 0; }
20
+ .poster figcaption { grid-column: 1; display: grid; gap: 14px; margin-bottom: 34px; }
21
+ .poster .kicker { font-size: 12px; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; }
22
+ .poster .headline { font: 400 68px/.94 "Instrument Serif", Georgia, serif; letter-spacing: -.01em; text-wrap: balance; }
23
+ .poster .headline em { color: #c8391a; }
24
+ .poster .dek { max-width: 52ch; font-size: 17px; line-height: 1.5; color: #48433c; }
25
+ .poster .note { display: block; margin-top: 26px; padding-top: 12px; border-top: 1px solid #d6d0c4; font-size: 12px; line-height: 1.5; color: #6b655c; }
26
+ /* The readout: the share of the world that speaks the language in focus, under a big quotation mark */
27
+ .readout { grid-column: 2; grid-row: 1; align-self: end; margin: 0 0 34px; font-size: 15px; line-height: 1.35; color: #48433c; }
28
+ .readout::before { content: "\201C" / ""; display: block; height: 72px; font: 400 200px/1 "Instrument Serif", Georgia, serif; color: #de4a24; }
29
+ .readout b { display: block; margin-bottom: 14px; font: 400 92px/.9 "Instrument Serif", Georgia, serif; color: #161412; }
30
+ .readout strong { display: block; font-size: 22px; font-weight: 600; color: #b8321a; }
31
+ /* On a phone the chart is taller than half the screen, so the readout stays at the top of the screen while the
32
+ chart scrolls under it (the poster is a flex column there: a sticky grid item can't leave its own row) */
33
+ @media (max-width: 760px) {
34
+ body { padding: 16px 16px 32px; }
35
+ .poster { display: flex; flex-direction: column; padding: 26px 18px 18px; }
36
+ .poster figcaption { margin-bottom: 18px; }
37
+ .poster .headline { font-size: 42px; }
38
+ .poster .dek { font-size: 15px; }
39
+ .readout { position: sticky; top: 0; z-index: 2; align-self: stretch; display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 14px; margin-bottom: 18px; padding: 10px 0; border-block: 1px solid #d6d0c4; background: inherit; }
40
+ .readout::before { display: none; }
41
+ .readout b { grid-row: span 2; margin: 0; font-size: 56px; }
42
+ .readout strong { font-size: 18px; }
43
+ }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div id="chart"></div>
48
+ <script type="module">
49
+ import { Chart, Plot, Bar, Label, Poster, slat, sortBy, nice, html, render, createSignal } from "@bezda/rhp/standalone";
50
+
51
+ // Data: total speakers (first and second language) in millions, from Ethnologue, 2023. Replace with your own.
52
+ const LANGUAGES = [
53
+ { name: "English", own: "English", millions: 1456 },
54
+ { name: "Mandarin Chinese", own: "普通话", millions: 1138 },
55
+ { name: "Hindi", own: "हिन्दी", millions: 610 },
56
+ { name: "Spanish", own: "Español", millions: 559 },
57
+ { name: "French", own: "Français", millions: 310 },
58
+ { name: "Standard Arabic", own: "العربية", millions: 274 },
59
+ { name: "Bengali", own: "বাংলা", millions: 273 },
60
+ { name: "Portuguese", own: "Português", millions: 264 },
61
+ { name: "Russian", own: "Русский", millions: 255 },
62
+ { name: "Urdu", own: "اردو", millions: 232 },
63
+ ];
64
+ const WORLD = 8045; // world population in 2023, millions (UN)
65
+
66
+ const count = new Intl.NumberFormat("en-US");
67
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumSignificantDigits: 2 });
68
+ const scale = nice(0, Math.max(...LANGUAGES.map((d) => d.millions)), 3); // 0 to 1,500, ticks every 500
69
+ const leader = LANGUAGES.reduce((best, d, i) => (d.millions > LANGUAGES[best].millions ? i : best), 0);
70
+
71
+ // Slat type: a language's name over its own name for itself, a speech-bubble bar, and its total
72
+ const Language = slat({
73
+ thickness: 46,
74
+ inset: "12px",
75
+ room: { start: "auto", end: 64 },
76
+ css: `
77
+ .name { font-size: 15px; font-weight: 600; line-height: 1.15; }
78
+ .name small { display: block; font-size: 12px; font-weight: 400; color: var(--rhp-muted); }
79
+ .bar { --fill: #8c867c; background: var(--fill); --rhp-radius: 10px; --rhp-start-radius: 4px; }
80
+ .bar::after { content: ""; position: absolute; left: 8px; top: calc(100% - 1px); border-top: 7px solid var(--fill); border-right: 9px solid transparent; transition: border-color .15s; }
81
+ .bar:vertical::after { display: none; }
82
+ .value { font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; --rhp-label-gap: 8px; }
83
+ .lit .bar { --fill: #de4a24; }
84
+ .lit .name, .lit .value { color: #b8321a; }
85
+ .language:focus-visible { outline: 2px solid #161412; outline-offset: -2px; border-radius: 4px; }
86
+ @container (max-width: 420px) { .name { font-size: 13px; } .name small { font-size: 11px; } .value { font-size: 12px; } }
87
+ @container (max-width: 300px) { .name { max-width: 6.5em; white-space: normal; } } /* long names wrap, so the bars keep their room */
88
+ `,
89
+ }, (d) => html`
90
+ <div class=${() => (d.lit ? "language lit" : "language")} data-row=${() => d.index} aria-label=${() => `${d.name}: ${count.format(d.millions)} million speakers`}>
91
+ <${Label} edge="start" class="name">${() => d.name}<small>${() => d.own}</small><//>
92
+ <${Bar} to=${() => d.millions} class="bar" />
93
+ <${Label} at=${() => d.millions} class="value">${() => count.format(d.millions)}<//>
94
+ </div>`);
95
+
96
+ // Chart component: the language the reader points at, taps or reaches with the keyboard is lit and read out
97
+ function SpokenLanguages() {
98
+ const [picked, setPicked] = createSignal(null);
99
+ const lit = () => picked() ?? leader;
100
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
101
+ const rowOf = (el) => {
102
+ const root = el.closest("[data-row]");
103
+ return root ? +root.dataset.row : null;
104
+ };
105
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
106
+ const pick = (e) => {
107
+ const row = rowOf(e.target);
108
+ if (row != null) setPicked(row);
109
+ };
110
+ // Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
111
+ const leave = (e) => {
112
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
113
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
114
+ };
115
+ return html`
116
+ <${Poster}
117
+ kicker="The world's languages"
118
+ title=${html`<em>English</em> is the world's most spoken language`}
119
+ dek="Total speakers in millions, counting everyone who speaks it as a first or a second language. Mandarin has more native speakers; English has more learners. Tap or point at a language."
120
+ note="Source: Ethnologue, 2023 (speakers); United Nations, 2023 (world population, 8.05 billion)."
121
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
122
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
123
+ <p class="readout" aria-live="polite">
124
+ <b>${() => percent.format(LANGUAGES[lit()].millions / WORLD)}</b>
125
+ <span>of people on Earth speak</span>
126
+ <strong>${() => LANGUAGES[lit()].name}</strong>
127
+ </p>
128
+ <${Chart} scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${(v) => count.format(v)}
129
+ label="The ten most spoken languages, total speakers in millions"
130
+ theme=${{ font: '"Instrument Sans", "Helvetica Neue", Arial, sans-serif', ink: "#161412", muted: "#6b655c", grid: "#ddd7cb" }}>
131
+ <${Plot} rows=${LANGUAGES} order=${sortBy("millions", "desc")} lit=${(d) => d.index === lit()} keyboard=${true}>${Language}<//>
132
+ <//>
133
+ <//>`;
134
+ }
135
+
136
+ render(() => html`<${SpokenLanguages} />`, document.getElementById("chart"));
137
+ </script>
138
+ </body>
139
+ </html>
@@ -0,0 +1,158 @@
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>Bikes beat cars, and keep to time</title>
7
+ <meta name="description" content="Box plot of commute times by mode of transport made with summary(): whiskers, the middle half as a box, the median and outliers, with a readout of the picked group. Use it to compare the spread of several groups at a glance.">
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=Overpass:wght@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 tiled station wall; the poster is the enamel map board hung on it */
16
+ body { margin: 0; padding: 48px 16px; font-family: Overpass, "Helvetica Neue", Arial, sans-serif; color: #16171a;
17
+ background: #e3e3de url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='64'%3E%3Cpath d='M0 1H96M0 33H96M1 0V32M49 32V64' fill='none' stroke='%23cdcdc8' stroke-width='2'/%3E%3C/svg%3E"); }
18
+ .poster { max-width: 960px; margin: 0 auto; padding: 0 44px 26px; border-radius: 6px; line-height: 1.45; overflow: hidden;
19
+ background: linear-gradient(170deg, #fdfdfb, #f5f5f1) #f8f8f4;
20
+ box-shadow: 0 0 0 1px rgb(0 0 0 / .08), 0 22px 40px -22px rgb(0 0 0 / .5); }
21
+ .poster figcaption { display: grid; gap: 10px; margin-bottom: 18px; }
22
+ /* The kicker is the station sign across the top */
23
+ .poster .kicker { display: block; margin: 0 -44px 18px; padding: 13px 44px 10px; background: #16171a; color: #fff;
24
+ font-size: 13px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
25
+ .poster .headline { font-size: 56px; font-weight: 900; line-height: .98; letter-spacing: -.025em; text-wrap: balance; }
26
+ .poster .dek { max-width: 58ch; font-size: 16px; color: #4a4c52; }
27
+ .poster .note { display: block; margin-top: 18px; font-size: 12px; color: #66686e; }
28
+ /* How to read a box, drawn with the chart's own marks */
29
+ .key { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 0 0 14px; padding: 0; list-style: none; font-size: 13px; color: #4a4c52; }
30
+ .key li { display: flex; align-items: center; gap: 8px; }
31
+ .key i { display: inline-block; flex: none; }
32
+ .key .line { width: 26px; height: 3px; border-radius: 2px; background: #8d9097; }
33
+ .key .box { width: 26px; height: 12px; border-radius: 6px; background: #8d9097; }
34
+ .key .stop { width: 4px; height: 18px; border-radius: 2px; background: #16171a; box-shadow: 0 0 0 2px #fbfbf8; }
35
+ .key .ring { width: 9px; height: 9px; border-radius: 50%; box-shadow: inset 0 0 0 2px #8d9097; }
36
+ /* The readout: the picked mode in one sentence, two lines tall so the chart never moves */
37
+ .readout { min-height: 3.1em; margin: 0 0 6px; padding: 10px 0 0; border-top: 2px solid #16171a; font-size: 16px; line-height: 1.45; }
38
+ .readout b { font-weight: 800; }
39
+ .readout i { display: inline-block; width: 12px; height: 12px; margin-right: 7px; border-radius: 50%; vertical-align: -1px; }
40
+ .axis-name { margin: 4px 0 0; font-size: 12px; font-weight: 700; color: #66686e; text-align: right; }
41
+ @media (max-width: 600px) {
42
+ body { padding: 16px; }
43
+ .poster { padding: 0 16px 18px; }
44
+ .poster .kicker { margin: 0 -16px 14px; padding: 11px 16px 8px; font-size: 11px; letter-spacing: .12em; }
45
+ .poster .headline { font-size: 36px; }
46
+ .poster .dek { font-size: 14.5px; }
47
+ .key { gap: 4px 14px; font-size: 12px; }
48
+ .readout { min-height: 4.4em; font-size: 14.5px; }
49
+ }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <div id="chart"></div>
54
+ <script type="module">
55
+ import { Chart, Plot, Bar, Dot, Tick, Label, Poster, slat, summary, sortBy, every, html, render, createSignal } from "@bezda/rhp/standalone";
56
+
57
+ // Data: one-way commute times in minutes, one per trip, by how people travel. Illustrative. Replace with your own.
58
+ const TRIPS = {
59
+ Walk: [8, 19, 13, 25, 17, 10, 21, 14, 28, 6, 17, 11, 22, 14, 40, 8, 18, 12, 25, 16, 10, 21, 15, 31],
60
+ Bike: [14, 22, 34, 18, 24, 21, 28, 16, 23, 19, 25, 11, 21, 30, 16, 22, 19, 26, 14, 21, 17, 23, 20, 28, 16, 22, 19, 26],
61
+ Car: [19, 28, 46, 22, 33, 17, 58, 25, 38, 19, 29, 11, 50, 23, 34, 17, 85, 26, 42, 20, 31, 15, 55, 24, 37, 19, 29, 48, 23, 35, 18, 72, 27, 42, 21, 32, 15, 54, 25, 37],
62
+ Metro: [30, 40, 26, 33, 19, 29, 37, 24, 32, 49, 28, 35, 23, 31, 40, 27, 34, 18, 29, 37, 24, 31, 42, 27, 34, 21, 29, 38, 24, 31, 57, 27, 34, 22],
63
+ Bus: [29, 40, 60, 33, 45, 24, 37, 53, 30, 41, 17, 69, 34, 46, 26, 38, 56, 31, 43, 23, 36, 50, 29, 41, 62, 34, 46, 26, 38, 56, 32, 44, 22, 80, 36, 49],
64
+ Train: [55, 45, 32, 51, 42, 60, 48, 38, 54, 45, 67, 29, 51, 41, 58, 46, 34, 52, 42, 60, 48, 38],
65
+ };
66
+ const LINE_COLORS = ["#1baf7a", "#eb6834", "#4a3aa7", "#eda100", "#2a78d6", "#e34948"]; // one per mode, in TRIPS order
67
+ const STORY = "Bike"; // the mode the readout opens on
68
+
69
+ // One row per mode: its name, color, number of trips and its box-plot numbers ({ low, q1, median, q3, high, outliers })
70
+ const MODES = Object.entries(TRIPS).map(([mode, trips], i) => ({ mode, color: LINE_COLORS[i], trips: trips.length, ...summary(trips) }));
71
+ const longest = Math.max(...MODES.map((m) => m.max));
72
+ const scaleMax = Math.ceil(longest / 15) * 15; // quarter hours
73
+ const minutes = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
74
+
75
+ // Slat types: an unusually slow trip is a ring; a mode is a transit line, its box the thick stretch and its median a station named with its time
76
+ const Outlier = slat({ css: `.slow { width: 9px; height: 9px; background: var(--rhp-surface); box-shadow: inset 0 0 0 2px var(--rhp-color); }` },
77
+ (o) => html`<${Dot} at=${() => o.at} color=${() => o.color} class="slow" />`);
78
+
79
+ const Mode = slat({
80
+ thickness: 58,
81
+ room: { start: "auto", end: 16 },
82
+ css: `
83
+ .name { display: flex; align-items: center; gap: 9px; justify-self: stretch; font-size: 15px; font-weight: 800; --rhp-label-gap: 16px; }
84
+ .name i { width: 13px; height: 13px; border-radius: 50%; background: var(--line); }
85
+ .range { --rhp-radius: 2px; background: var(--line); }
86
+ .end { --rhp-tick-width: 3px; background: var(--line); border-radius: 2px; }
87
+ .middle { --rhp-radius: 99px; background: var(--line); }
88
+ .stop { --rhp-tick-width: 4px; border-radius: 2px; background: #16171a; box-shadow: 0 0 0 2px var(--rhp-surface); }
89
+ .time { top: calc(50% - 13px); translate: -50% -100%; padding: 0; font-size: 12.5px; font-weight: 800; }
90
+ .mode:focus-visible { outline: 2px solid #16171a; outline-offset: -2px; border-radius: 6px; }
91
+ .mode.on { background: rgb(22 23 26 / .05); border-radius: 6px; }
92
+ .dim > :not(.name, .time) { opacity: .28; }
93
+ .dim :is(.name, .time) { color: var(--rhp-muted); }
94
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
95
+ `,
96
+ }, (d) => html`
97
+ <div class=${() => (d.picked == null ? "mode" : d.picked === d.index ? "mode on" : "mode dim")} data-row=${() => d.index} style=${() => ({ "--line": d.color })}>
98
+ <${Label} edge="start" class="name"><i></i>${() => d.mode}<//>
99
+ <${Bar} from=${() => d.low} to=${() => d.high} thick="3px" class="range" />
100
+ <${Tick} at=${() => d.low} thick="12px" class="end" />
101
+ <${Tick} at=${() => d.high} thick="12px" class="end" />
102
+ <${Bar} from=${() => d.q1} to=${() => d.q3} thick="12px" class="middle" />
103
+ <${Plot} overlap=${true} at=${() => d.outliers} color=${() => d.color}>${Outlier}<//>
104
+ <${Tick} at=${() => d.median} thick="20px" class="stop" />
105
+ <${Label} at=${() => d.median} class="time">${() => `${minutes.format(d.median)} min`}<//>
106
+ <span class="say">${() => `: half of all trips take ${minutes.format(d.q1)} to ${minutes.format(d.q3)} minutes, usually ${minutes.format(d.low)} to ${minutes.format(d.high)}`}</span>
107
+ </div>`);
108
+
109
+ // Chart component: the picked mode (pointer, tap or arrow keys) is read out above the chart and the others fade
110
+ function CommuteTimes() {
111
+ const [picked, setPicked] = createSignal(null);
112
+ const story = Math.max(0, MODES.findIndex((m) => m.mode === STORY));
113
+ const shown = () => MODES[picked() ?? story];
114
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
115
+ const rowOf = (el) => {
116
+ const root = el.closest("[data-row]");
117
+ return root ? +root.dataset.row : null;
118
+ };
119
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
120
+ const pick = (e) => {
121
+ const row = rowOf(e.target);
122
+ if (row != null) setPicked(row);
123
+ };
124
+ // Leaving the poster: back to the slat that has focus (a keyboard's or a click's pick), or none
125
+ const leave = (e) => {
126
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
127
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
128
+ };
129
+ return html`
130
+ <${Poster}
131
+ kicker="Commute times, door to door"
132
+ title="Bikes beat cars, and keep to time"
133
+ dek=${`One-way commutes timed over a month, ${MODES.reduce((n, m) => n + m.trips, 0)} trips in all. A cyclist's trips stay within a few minutes of each other; a driver's can take twice as long on a bad day. Tap or point at a mode for its numbers.`}
134
+ note="Illustrative data."
135
+ onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
136
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
137
+ <ul class="key">
138
+ <li><i class="box"></i>Middle half of trips</li>
139
+ <li><i class="stop"></i>Typical trip (median)</li>
140
+ <li><i class="line"></i>Usual range</li>
141
+ <li><i class="ring"></i>Unusually slow trip</li>
142
+ </ul>
143
+ <p class="readout">
144
+ <i style=${() => ({ background: shown().color })}></i><b>${() => shown().mode}</b>: the typical trip takes <b>${() => minutes.format(shown().median)} minutes</b>, and half of all trips take between ${() => minutes.format(shown().q1)} and ${() => minutes.format(shown().q3)}.
145
+ </p>
146
+ <${Chart} scale=${[0, scaleMax]} ticks=${every(15)} format=${(v) => minutes.format(v)}
147
+ label="Box plot of one-way commute times in minutes, by mode of transport"
148
+ theme=${{ font: 'Overpass, "Helvetica Neue", Arial, sans-serif', ink: "#16171a", muted: "#66686e", grid: "#e2e2dd", surface: "#fbfbf8" }}>
149
+ <${Plot} rows=${MODES} order=${sortBy("median")} picked=${picked} keyboard=${true}>${Mode}<//>
150
+ <//>
151
+ <p class="axis-name">Minutes, door to door</p>
152
+ <//>`;
153
+ }
154
+
155
+ render(() => html`<${CommuteTimes} />`, document.getElementById("chart"));
156
+ </script>
157
+ </body>
158
+ </html>
@@ -0,0 +1,182 @@
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>Two ways to hold 20 million</title>
7
+ <meta name="description" content="Bubble chart: two measures per item on a second axis (log scales) and a third as the bubble's area, with guide lines of equal size, labels for the story's items and a readout for the bubble under the pointer, a tap or the arrow keys. Use it for items with two measures and a size.">
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@400..700&family=DM+Mono:wght@400;500&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: #3a3d42; font-family: Sora, "Helvetica Neue", Arial, sans-serif; }
15
+ /* The poster: a city seen from orbit at night, with a map's scale bar under the kicker */
16
+ .poster { max-width: 920px; margin: 0 auto; padding: 44px 48px 26px; color: #ece8df; border-radius: 6px; background: #0e1116;
17
+ box-shadow: 0 30px 60px -30px rgb(0 0 0 / .8); }
18
+ .poster figcaption { display: grid; gap: 12px; margin-bottom: 18px; }
19
+ .poster .kicker { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font: 500 12px/1.3 "DM Mono", ui-monospace, Menlo, monospace; letter-spacing: .14em; text-transform: uppercase; color: #f5a524; }
20
+ .scalebar { display: inline-grid; grid-template-columns: repeat(2, 36px); row-gap: 3px; font-size: 10px; letter-spacing: .04em; color: #9aa1aa; }
21
+ .scalebar::before { content: ""; grid-column: 1 / -1; height: 5px; border: 1px solid #9aa1aa; background: linear-gradient(90deg, #9aa1aa 0 25%, transparent 0 50%, #9aa1aa 0 75%, transparent 0); }
22
+ .scalebar span:last-child { text-align: right; }
23
+ .poster .headline { max-width: 16em; font-size: 46px; font-weight: 700; line-height: 1.04; letter-spacing: -.03em; text-wrap: balance; }
24
+ .poster .dek { max-width: 60ch; font-size: 15px; line-height: 1.55; color: #b9bcc1; }
25
+ .poster .note { display: block; margin-top: 14px; font-size: 12px; line-height: 1.5; color: #9aa1aa; }
26
+ /* The readout: the city picked, one line tall (two on a phone) whatever the city, so the chart never moves */
27
+ .readout { box-sizing: content-box; min-height: 1.4em; margin: 0 0 8px; padding: 9px 0; border-block: 1px solid rgb(255 255 255 / .12);
28
+ font: 13px/1.4 "DM Mono", ui-monospace, monospace; color: #b9bcc1; }
29
+ .readout b { font-family: Sora, sans-serif; font-weight: 600; color: #ece8df; }
30
+ .axis { margin: 0 0 8px; font-size: 11px; color: #9aa1aa; }
31
+ .axis.x { margin: 8px 0 0; text-align: right; }
32
+ .plot { border-radius: 4px; }
33
+ .plot:has(:focus-visible) { outline: 2px solid #ece8df; outline-offset: 4px; } /* 6px out: clear of the axis names, 8px away */
34
+ @media (max-width: 600px) {
35
+ body { padding: 16px 16px 32px; }
36
+ .poster { padding: 26px 18px 18px; }
37
+ .poster .headline { font-size: 32px; }
38
+ .readout { min-height: 2.8em; font-size: 12px; }
39
+ }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div id="chart"></div>
44
+ <script type="module">
45
+ import { Chart, Plot, Scale, Line, Dot, Tick, Label, Poster, slat, html, render, createSignal, Show } from "@bezda/rhp/standalone";
46
+
47
+ // Data: built-up urban areas from Demographia World Urban Areas, 20th edition (2025): people, land in km², people per
48
+ // km². Replace with your own rows, largest first, so the small bubbles are drawn on top. `side` labels a bubble, and
49
+ // `code` is the short label it shows where the chart is too narrow for its name (never hide a label instead).
50
+ const CITIES = [
51
+ { name: "Tokyo", code: "TYO", people: 37325000, km2: 8775, density: 4254, side: "after" },
52
+ { name: "Jakarta", people: 36877000, km2: 3546, density: 10400 },
53
+ { name: "Delhi", people: 33224000, km2: 2344, density: 14174 },
54
+ { name: "Mumbai", people: 26237000, km2: 1067, density: 24588, side: "after" },
55
+ { name: "Cairo", people: 22684000, km2: 2696, density: 8413 },
56
+ { name: "São Paulo", people: 21747000, km2: 3649, density: 5959 },
57
+ { name: "New York", code: "NYC", people: 20892000, km2: 11344, density: 1842, side: "before" },
58
+ { name: "Kinshasa", people: 13060000, km2: 518, density: 25212 },
59
+ { name: "London", people: 11360000, km2: 1738, density: 6537 },
60
+ { name: "Paris", people: 11282000, km2: 2854, density: 3953 },
61
+ { name: "Hong Kong", people: 7117000, km2: 290, density: 24535 },
62
+ { name: "Atlanta", code: "ATL", people: 5495000, km2: 7151, density: 768, side: "after" },
63
+ ];
64
+ const STORY = CITIES.findIndex((c) => c.name === "Mumbai"); // read out when nothing is picked
65
+
66
+ // Both axes are log scales: the chart gets log10 of each value, and the ticks are turned back into numbers.
67
+ // A bubble's area follows its population: its diameter is 8% of the chart's width (8cqw) for the largest city.
68
+ const X = [2.2, 4.4]; // log10 of land area, about 160 to 25,000 km²
69
+ const Y = [2.6, 4.7]; // log10 of people per km², about 400 to 50,000
70
+ const MAX = Math.max(...CITIES.map((c) => c.people));
71
+ const diameter = (people) => 8 * Math.sqrt(people / MAX); // in cqw
72
+ const count = new Intl.NumberFormat("en-US");
73
+ const millions = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
74
+
75
+ // Guide lines: on these log scales, the places where land times density gives the same number of people are diagonals
76
+ const GUIDES = [10e6, 30e6].map((people) => {
77
+ const top = Math.log10(people) - Y[1]; // where the diagonal meets the top of the plot
78
+ return { people, top, points: [[top, Y[1]], [X[1], Math.log10(people) - X[1]]] };
79
+ });
80
+
81
+ // Slat types
82
+ // The scale under the plot: a short mark and the land area at each tick. Its room also makes the left side wider than
83
+ // the cross axis' default 42px, so its widest number (30,000) stays inside the chart.
84
+ const Land = slat({
85
+ room: { start: 48, after: 26 },
86
+ css: `
87
+ .mark { top: 100%; height: 6px; translate: -50% 0; --rhp-tick-width: 1px; background: rgb(255 255 255 / .35); }
88
+ .amount { top: calc(100% + 10px); bottom: auto; translate: -50% 0; --rhp-label-gap: 0px; font-size: 11px; color: var(--rhp-muted); }
89
+ `,
90
+ }, (t) => html`
91
+ <div>
92
+ <${Tick} at=${() => t.at} class="mark" />
93
+ <${Label} at=${() => t.at} class="amount">${() => count.format(Math.round(10 ** t.at))}<//>
94
+ </div>`);
95
+
96
+ // A guide line, named at its top end (in millions on a phone)
97
+ const Guide = slat({
98
+ css: `
99
+ .guide { stroke: rgb(255 255 255 / .28); stroke-width: 1px; }
100
+ .people { --rhp-label-gap: 7px; font-size: 11px; color: #9aa1aa; }
101
+ .short { display: none; }
102
+ @container (max-width: 520px) { .long { display: none; } .short { display: inline; } }
103
+ `,
104
+ }, (d) => html`
105
+ <div>
106
+ <${Line} points=${() => d.points} class="guide" />
107
+ <${Label} at=${() => d.top} cross=${Y[1] - 0.08} class="people">
108
+ <span class="long">${() => `${millions.format(d.people / 1e6)} million people`}</span><span class="short">${() => `${millions.format(d.people / 1e6)}M`}</span>
109
+ <//>
110
+ </div>`);
111
+
112
+ // A city: its bubble, its name when the data labels it (its code on a narrow chart), and a sentence for screen readers. Only the bubble takes the
113
+ // pointer, since the slat itself covers the whole plot.
114
+ const City = slat({
115
+ css: `
116
+ .city { pointer-events: none; outline: none; }
117
+ .bubble { pointer-events: auto; cursor: pointer; background: rgb(245 165 36 / .3); box-shadow: inset 0 0 0 1.5px #f5a524; }
118
+ .bubble::before { content: ""; position: absolute; inset: -6px; border-radius: 50%; }
119
+ .labeled .bubble { background: rgb(245 165 36 / .55); }
120
+ .on .bubble { z-index: 2; background: rgb(245 165 36 / .7); box-shadow: inset 0 0 0 1.5px #f5a524, 0 0 0 2px #0e1116, 0 0 0 4px #ece8df; }
121
+ .name { font-size: 13px; font-weight: 600; }
122
+ .code { display: none; }
123
+ @container (max-width: 260px) { .full { display: none; } .code { display: inline; } } /* where the names stop fitting */
124
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
125
+ `,
126
+ }, (d) => html`
127
+ <div class=${() => ["city", d.side && "labeled", d.on && "on"].filter(Boolean).join(" ")} data-i=${() => d.index}>
128
+ <${Dot} at=${() => Math.log10(d.km2)} cross=${() => Math.log10(d.density)} size=${() => `${diameter(d.people)}cqw`} class="bubble" />
129
+ <${Show} when=${() => d.side}>
130
+ <${Label} at=${() => Math.log10(d.km2)} cross=${() => Math.log10(d.density)} side=${() => d.side}
131
+ style=${() => ({ "--rhp-label-gap": `calc(${diameter(d.people) / 2}cqw + 6px)` })} class="name">
132
+ <span class="full">${() => d.name}</span><span class="code">${() => d.code ?? d.name}</span>
133
+ <//>
134
+ <//>
135
+ <span class="say">${() => `${d.name}: ${millions.format(d.people / 1e6)} million people on ${count.format(d.km2)} square kilometres, ${count.format(d.density)} per square kilometre`}</span>
136
+ </div>`);
137
+
138
+ // Chart component: the readout and the ring show the city under the pointer, a tap or the arrow keys, and Mumbai otherwise
139
+ function CitiesChart() {
140
+ const [picked, setPicked] = createSignal(null);
141
+ const shown = () => CITIES[picked() ?? STORY];
142
+ // The row of the slat an element is in, or null (between slats, on the axis or the text)
143
+ const rowOf = (el) => {
144
+ const root = el.closest("[data-i]");
145
+ return root ? +root.dataset.i : null;
146
+ };
147
+ // A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no slat the pick stays
148
+ const pick = (e) => {
149
+ const row = rowOf(e.target);
150
+ if (row != null) setPicked(row);
151
+ };
152
+ // Leaving the chart: back to the slat that has focus (a keyboard's or a click's pick), or none
153
+ const leave = (e) => {
154
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
155
+ setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
156
+ };
157
+ return html`
158
+ <${Poster}
159
+ kicker=${html`<span>Megacities from above <span class="scalebar" aria-hidden="true"><span>0</span><span>10 km</span></span></span>`}
160
+ title="Mumbai fits more people than New York on a tenth of the land"
161
+ dek="Twelve of the world's largest built-up areas by land and by crowding. Bubble area shows population; along each diagonal, cities hold the same number of people. Tap or point at a city."
162
+ note="Source: Demographia World Urban Areas, 20th edition (2025), built-up urban areas.">
163
+ <p class="readout"><b>${() => shown().name}</b><span>${() => `: ${millions.format(shown().people / 1e6)} million people on ${count.format(shown().km2)} km², ${count.format(shown().density)} per km²`}</span></p>
164
+ <p class="axis">People per km², log scale</p>
165
+ <div class="plot" onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
166
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
167
+ <${Chart} scale=${X} cross=${Y} crossTicks=${[500, 1000, 3000, 10000, 30000].map(Math.log10)} crossFormat=${(v) => count.format(Math.round(10 ** v))} height=${360}
168
+ label="Twelve megacities by land area and density, bubble area for population"
169
+ theme=${{ font: 'Sora, "Helvetica Neue", Arial, sans-serif', ink: "#ece8df", muted: "#9aa1aa", grid: "rgb(255 255 255 / .1)" }}>
170
+ <${Scale} ticks=${[300, 1000, 3000, 10000].map(Math.log10)}>${Land}<//>
171
+ <${Plot} overlap=${true} rows=${GUIDES} style=${{ "pointer-events": "none" }}>${Guide}<//>
172
+ <${Plot} overlap=${true} keyboard=${true} rows=${CITIES} on=${(d) => d.index === picked()}>${City}<//>
173
+ <//>
174
+ </div>
175
+ <p class="axis x">Built-up land, km², log scale</p>
176
+ <//>`;
177
+ }
178
+
179
+ render(() => html`<${CitiesChart} />`, document.getElementById("chart"));
180
+ </script>
181
+ </body>
182
+ </html>
@@ -0,0 +1,132 @@
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>Sales against target by region</title>
7
+ <meta name="description" content="Bullet chart: actual against target for each item, over gray bands for poor, fair and good, with a switch that sorts by the actual value or by the share of target. Use it for performance against goals, budgets or quotas.">
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
+ * { box-sizing: border-box; }
14
+ body { margin: 0; padding: 48px 16px; background: #d4d3cf; font-family: Archivo, "Helvetica Neue", Arial, sans-serif; }
15
+ /* The poster: a page of an annual report, black on white with one orange, and the year on a tab under the top rule */
16
+ .poster { position: relative; display: grid; max-width: 960px; margin: 0 auto; padding: 48px 52px 30px; overflow: hidden; color: #141414;
17
+ background: linear-gradient(#141414 0 0) 52px 34px / calc(100% - 104px) 3px no-repeat, #fcfcfa; box-shadow: 0 1px 2px rgb(30 29 26 / .2), 0 26px 48px -28px rgb(30 29 26 / .55); }
18
+ .poster > * { min-width: 0; }
19
+ .poster::after { content: "FY 2025"; position: absolute; top: 37px; right: 52px; padding: 7px 10px 6px; background: #141414; color: #fcfcfa;
20
+ font: 800 12px/1 Archivo, Arial, sans-serif; font-stretch: 112%; letter-spacing: .14em; }
21
+ .poster figcaption { display: grid; gap: 12px; margin-bottom: 20px; padding-top: 18px; }
22
+ .poster .kicker { font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: #c4430c; }
23
+ .poster .headline { max-width: 15em; font-size: 50px; font-weight: 800; font-stretch: 112%; line-height: .98; letter-spacing: -.03em; text-wrap: balance; }
24
+ .poster .dek { max-width: 56ch; font-size: 16px; line-height: 1.5; color: #4b4a47; }
25
+ .poster .note { display: block; margin-top: 18px; padding-top: 12px; border-top: 1px solid #141414; font-size: 12px; line-height: 1.5; color: #5d5c58; }
26
+ /* The key and the sort switch, on one line above the chart */
27
+ .bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 20px; margin-bottom: 18px; }
28
+ .key { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 0; font-size: 12px; color: #4b4a47; }
29
+ .key span { display: inline-flex; align-items: center; gap: 6px; }
30
+ .key i { display: inline-block; width: 16px; height: 12px; }
31
+ .switch { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
32
+ .switch button { padding: 6px 11px; border: 1.5px solid #141414; border-radius: 99px; background: none; font: 700 13px/1 Archivo, Arial, sans-serif; color: #141414; cursor: pointer; }
33
+ .switch button[aria-pressed="true"] { background: #141414; color: #fcfcfa; }
34
+ .switch button:focus-visible { outline: 2px solid #e04e0e; outline-offset: 2px; }
35
+ @media (max-width: 560px) {
36
+ body { padding: 24px 16px 32px; }
37
+ .poster { padding: 38px 18px 20px; background-position: 18px 26px, 0 0; background-size: calc(100% - 36px) 3px, auto; }
38
+ .poster::after { top: 29px; right: 18px; padding: 6px 8px 5px; font-size: 11px; }
39
+ .poster figcaption { padding-top: 30px; }
40
+ .poster .headline { font-size: 34px; }
41
+ .poster .dek { font-size: 15px; }
42
+ }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div id="chart"></div>
47
+ <script type="module">
48
+ import { Chart, Plot, Bar, Tick, Label, Poster, slat, sortBy, nice, html, render, createSignal } from "@bezda/rhp/standalone";
49
+
50
+ // Data: a fictional company's sales and target for each region in a year, in millions of US dollars. Illustrative.
51
+ // Replace with your own.
52
+ const REGIONS = [
53
+ { region: "North America", sales: 12.4, target: 11 },
54
+ { region: "Europe", sales: 9.1, target: 10 },
55
+ { region: "Asia Pacific", sales: 10.6, target: 9.5 },
56
+ { region: "Latin America", sales: 3, target: 4 },
57
+ { region: "Middle East & Africa", sales: 2.1, target: 2 },
58
+ ];
59
+ // The bands, as shares of each region's target: poor below 80%, fair to 100%, good to 120%
60
+ const POOR = 0.8, GOOD = 1.2;
61
+ const BANDS = { poor: "#928e86", fair: "#b4b1aa", good: "#e0ded8" };
62
+ const ORANGE = "#e04e0e";
63
+
64
+ const scale = nice(0, Math.max(...REGIONS.map((r) => Math.max(r.sales, r.target * GOOD))), 5);
65
+ const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 1, maximumFractionDigits: 1 });
66
+ const tickMoney = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1 });
67
+ const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
68
+
69
+ // Slat type: a region's name and numbers over its bullet. The bands are three Bars end to end, the sales a thinner Bar
70
+ // over them, the target a Tick across, and the share of target sits past the chart's end.
71
+ const Region = slat({
72
+ thickness: 74,
73
+ room: { start: 4, end: 92 },
74
+ css: `
75
+ .head { position: absolute; top: 2px; left: 0; white-space: nowrap; font-size: 14px; }
76
+ .head b { font-weight: 750; }
77
+ .head span { color: #5d5c58; font-variant-numeric: tabular-nums; }
78
+ .band { --rhp-radius: 0px; }
79
+ .poor { --rhp-start-radius: 3px; }
80
+ .good { --rhp-end-radius: 3px; }
81
+ .sales { --rhp-radius: 1px; }
82
+ .target { --rhp-tick-width: 3px; border-radius: 2px; }
83
+ .pct { font-size: 20px; font-weight: 800; font-stretch: 110%; letter-spacing: -.02em; text-align: right; font-variant-numeric: tabular-nums; }
84
+ .pct small { font-size: 11px; vertical-align: 3px; margin-right: 3px; }
85
+ .short .pct { color: #5d5c58; }
86
+ @container (max-width: 480px) { .head { font-size: 13px; } .pct { font-size: 17px; } }
87
+ `,
88
+ }, (d) => html`
89
+ <div class=${() => (d.sales >= d.target ? "region" : "region short")}>
90
+ <div class="head"><b>${() => d.region}</b><span>${() => ` ${money.format(d.sales * 1e6)} of ${money.format(d.target * 1e6)}`}</span></div>
91
+ <${Bar} to=${() => d.target * POOR} thick="22px" color=${BANDS.poor} class="band poor" />
92
+ <${Bar} from=${() => d.target * POOR} to=${() => d.target} thick="22px" color=${BANDS.fair} class="band fair" />
93
+ <${Bar} from=${() => d.target} to=${() => d.target * GOOD} thick="22px" color=${BANDS.good} class="band good" />
94
+ <${Bar} to=${() => d.sales} thick="8px" color="ink" class="sales" />
95
+ <${Tick} at=${() => d.target} thick="32px" color=${ORANGE} class="target" />
96
+ <${Label} edge="end" class="pct"><small>${() => (d.sales >= d.target ? "▲" : "▼")}</small>${() => percent.format(d.sales / d.target)}<//>
97
+ </div>`);
98
+
99
+ // Chart component: the switch sorts the regions by sales or by their share of target, and the slats slide
100
+ function SalesByRegion() {
101
+ const [by, setBy] = createSignal("sales");
102
+ const SORTS = { sales: "Sales", share: "% of target" };
103
+ return html`
104
+ <${Poster}
105
+ kicker="Sales by region"
106
+ title="Three of five regions beat their sales target"
107
+ dek="Each region against its own target. Latin America fell furthest short, at three quarters of its target; North America beat its target by the most."
108
+ note="Illustrative data for a fictional company, in millions of US dollars. Bands: poor is under 80% of target, fair is 80 to 100%, good is above it.">
109
+ <div class="bar">
110
+ <p class="key">
111
+ <span><i style=${{ background: BANDS.poor }}></i>Poor</span>
112
+ <span><i style=${{ background: BANDS.fair }}></i>Fair</span>
113
+ <span><i style=${{ background: BANDS.good }}></i>Good</span>
114
+ <span><i style=${{ height: "4px", background: "#141414" }}></i>Sales</span>
115
+ <span><i style=${{ width: "3px", height: "14px", background: ORANGE }}></i>Target</span>
116
+ </p>
117
+ <span class="switch" role="group" aria-label="Sort by">Sort
118
+ ${Object.keys(SORTS).map((k) => html`<button type="button" aria-pressed=${() => by() === k} onClick=${() => setBy(k)}>${SORTS[k]}</button>`)}
119
+ </span>
120
+ </div>
121
+ <${Chart} scale=${[scale.min, scale.max]} ticks=${scale.ticks} format=${(v) => (v ? tickMoney.format(v * 1e6) : "$0")}
122
+ label="Sales against target by region, in millions of US dollars"
123
+ theme=${{ font: "Archivo, 'Helvetica Neue', Arial, sans-serif", ink: "#141414", muted: "#5d5c58", grid: "#e2e0db" }}>
124
+ <${Plot} rows=${REGIONS} rank=${(d) => (by() === "sales" ? d.sales : d.sales / d.target)} order=${sortBy("rank", "desc")}>${Region}<//>
125
+ <//>
126
+ <//>`;
127
+ }
128
+
129
+ render(() => html`<${SalesByRegion} />`, document.getElementById("chart"));
130
+ </script>
131
+ </body>
132
+ </html>