@bezda/rhp-mcp 0.1.0 → 0.1.2
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/README.md +20 -5
- package/bin/rhp-mcp.js +5 -1
- package/package.json +3 -3
- package/skill/SKILL.md +116 -26
- package/skill/recipes/area.html +6 -0
- package/skill/recipes/bar.html +9 -1
- package/skill/recipes/box-plot.html +9 -1
- package/skill/recipes/bubble.html +6 -0
- package/skill/recipes/bullet.html +6 -0
- package/skill/recipes/candlestick.html +9 -1
- package/skill/recipes/column.html +18 -5
- package/skill/recipes/dial.html +349 -0
- package/skill/recipes/diverging-bars.html +6 -0
- package/skill/recipes/donut.html +9 -1
- package/skill/recipes/dumbbell.html +6 -0
- package/skill/recipes/gantt.html +17 -5
- package/skill/recipes/grouped-bars.html +6 -0
- package/skill/recipes/heatmap.html +9 -1
- package/skill/recipes/histogram.html +9 -1
- package/skill/recipes/line.html +7 -1
- package/skill/recipes/live.html +6 -0
- package/skill/recipes/log-line.html +493 -0
- package/skill/recipes/lollipop.html +6 -0
- package/skill/recipes/multi-line.html +10 -5
- package/skill/recipes/pyramid.html +9 -1
- package/skill/recipes/race.html +6 -0
- package/skill/recipes/radial-bars.html +6 -0
- package/skill/recipes/scatter.html +23 -4
- package/skill/recipes/silhouettes.html +354 -0
- package/skill/recipes/slope.html +23 -4
- package/skill/recipes/sparklines.html +7 -1
- package/skill/recipes/stacked-100.html +6 -0
- package/skill/recipes/stacked-bars.html +6 -0
- package/skill/recipes/strip.html +9 -1
- package/skill/recipes/unit-stack.html +276 -0
- package/skill/recipes/violin.html +6 -0
- package/skill/recipes/waffle.html +6 -0
- package/skill/recipes/waterfall.html +6 -0
- package/skill/references/api.md +150 -22
- package/skill/references/design.md +303 -326
- package/skill/references/environments.md +11 -4
- package/skill/references/forms.md +41 -4
- package/skill/references/interaction.md +7 -2
- package/skill/references/looks.md +267 -0
- package/skill/references/performance.md +143 -0
- package/skill/references/pitfalls.md +52 -7
- package/src/check/README.md +33 -3
- package/src/check/browser.js +11 -5
- package/src/check/findings.js +15 -1
- package/src/check/index.js +182 -31
- package/src/check/inpage.js +82 -18
- package/src/check/interact.js +1 -19
- package/src/check/network.js +168 -0
- package/src/check/png.js +18 -0
- package/src/check/report.js +4 -1
- package/src/check/selection.js +123 -0
- package/src/guard/rhp.js +4 -0
- package/src/guard/rules.js +1 -1
- package/src/server.js +9 -8
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Japan's population by age</title>
|
|
7
7
|
<meta name="description" content="Population pyramid: two sides of one population (men and women) by age band, oldest on top, with an age picked by pointer, tap or keyboard that lights everyone that age or older and reads out their share. Use it for any two groups compared across ordered bands.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: a Bar to each side of zero per age band, and an age picked by pointer, tap or keyboard that lights everyone that age or older.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (Japan's population on washi paper in sumi ink, the two sides in hanada blue and vermilion).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
16
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Zen+Old+Mincho:wght@700;900&family=Zen+Kaku+Gothic+New:wght@400;500;700&display=swap">
|
|
@@ -126,6 +132,8 @@ function AgeingJapan() {
|
|
|
126
132
|
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
127
133
|
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
128
134
|
};
|
|
135
|
+
// Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
|
|
136
|
+
const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
|
|
129
137
|
const older = () => MEN.slice(from()).concat(WOMEN.slice(from())).reduce((sum, v) => sum + v, 0);
|
|
130
138
|
return html`
|
|
131
139
|
<${Poster}
|
|
@@ -133,7 +141,7 @@ function AgeingJapan() {
|
|
|
133
141
|
title="Nearly three in ten people in Japan are 65 or older"
|
|
134
142
|
dek="Men to the left, women to the right, in millions, the oldest at the top. Two bulges show the baby booms of the late 1940s and early 1970s; the base keeps shrinking."
|
|
135
143
|
note="Source: Statistics Bureau of Japan, population estimates for October 1, 2023 (124.35 million people), rounded to the nearest 10,000. Point at an age band, tap it, or Tab into the chart and use the arrow keys."
|
|
136
|
-
onPointerMove=${
|
|
144
|
+
onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
|
|
137
145
|
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
138
146
|
<p class="readout" aria-live="polite">
|
|
139
147
|
<b>${() => percent.format(older() / TOTAL)}</b>
|
package/skill/recipes/race.html
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>China's economy caught up</title>
|
|
7
7
|
<meta name="description" content="Bar chart race: ten economies ranked by GDP from 1990 to 2024, the bars gliding from year to year and sliding past each other as ranks change, with a play button, a year slider that lands on whole years, and a readout on hover, focus and tap. A playhead moves on every frame and each value is interpolated between the years around it, so the bars move by rhp's JS version (animate). Use it for a ranking that changes over time.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: JS motion on a continuous playhead, bars that glide and pass each other, play and pause, a year slider, and a scale that follows the leader.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (three decades of GDP as an engraved banknote: cotton paper, a guilloche rosette, the year in engraved numerals).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
16
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400..800;1,6..96,400..800&family=Jost:wght@400;500;600&display=swap">
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>A week of daily goals</title>
|
|
7
7
|
<meta name="description" content="Radial bars: one ring per goal, its arc the share of the goal reached, with a day switch that sweeps the arcs to another day. Use it for progress toward a few goals or targets, in percent.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: rings drawn in the slat's CSS from where each Bar starts and ends on the scale, and a day switch that sweeps the arcs.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (a week of daily goals on a dotted planner page taped to the wall, each day's switch dotted with the goals it met).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
16
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Young+Serif&family=Hanken+Grotesk:wght@400..800&display=swap">
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Wealth and health</title>
|
|
7
7
|
<meta name="description" content="Scatter plot of two measures per item on a second axis, with a log scale, a few points labeled (short codes on phones) and a readout of the point nearest the pointer, a tap or the arrow keys. Use it to show how two measures relate across items.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: Dots on two axes with a log scale, a few points labeled (short codes on phones), and a readout of the nearest point.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (wealth and health on banknote paper, a quarter of an engraved seal in the corner).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
16
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:opsz,wght@6..96,500..800&family=Manrope:wght@400..700&display=swap">
|
|
@@ -129,6 +135,7 @@ const Country = slat({
|
|
|
129
135
|
// keys, and the United States otherwise
|
|
130
136
|
function WealthChart() {
|
|
131
137
|
const [picked, setPicked] = createSignal(null);
|
|
138
|
+
const [pinned, setPinned] = createSignal(null); // the dot a click or a tap picked: the chart comes back to it
|
|
132
139
|
const shown = () => COUNTRIES[picked() ?? STORY];
|
|
133
140
|
let plot;
|
|
134
141
|
const nearest = (e) => {
|
|
@@ -155,18 +162,30 @@ function WealthChart() {
|
|
|
155
162
|
const i = nearest(e);
|
|
156
163
|
if (i != null) setPicked(i);
|
|
157
164
|
};
|
|
165
|
+
// A tap or a click picks it too, and pins it
|
|
166
|
+
const pin = (e) => {
|
|
167
|
+
point(e);
|
|
168
|
+
setPinned(picked());
|
|
169
|
+
};
|
|
158
170
|
// The keyboard picks the dot it focuses
|
|
159
171
|
const focus = (e) => {
|
|
160
172
|
const i = rowOf(e.target);
|
|
161
173
|
if (i != null) setPicked(i);
|
|
162
174
|
};
|
|
163
|
-
// Leaving the chart: back to the dot that has focus (the keyboard's pick),
|
|
175
|
+
// Leaving the chart: back to the dot that has focus (the keyboard's pick), else the pinned one, else none
|
|
164
176
|
const leave = (e) => {
|
|
165
177
|
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
166
|
-
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) :
|
|
178
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : pinned());
|
|
179
|
+
};
|
|
180
|
+
// Clicking poster text or margin resets this existing pick to the chart's original default.
|
|
181
|
+
const reset = (e) => {
|
|
182
|
+
if (e.target.closest(".plot, a, button, input, select, textarea, label, [role=button], [role=slider]")) return;
|
|
183
|
+
setPinned(null);
|
|
184
|
+
setPicked(null);
|
|
167
185
|
};
|
|
168
186
|
return html`
|
|
169
187
|
<${Poster}
|
|
188
|
+
onClick=${reset}
|
|
170
189
|
kicker="Wealth and health of nations"
|
|
171
190
|
title="Life expectancy climbs fast with income, then levels off"
|
|
172
191
|
dek="Twenty countries in 2023. Above about $25,000 a person, higher income goes with little extra life: the United States, richer than all but Norway here, has a lower life expectancy than Costa Rica, with about a third of its income. Tap or point at a dot."
|
|
@@ -174,8 +193,8 @@ function WealthChart() {
|
|
|
174
193
|
<p class="readout"><b>${() => shown().name}</b><span>${() => `: ${dollars.format(shown().gdp)} a person, ${years.format(shown().life)} years`}</span></p>
|
|
175
194
|
<p class="axis">Life expectancy at birth, years</p>
|
|
176
195
|
<div class="plot"
|
|
177
|
-
onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${
|
|
178
|
-
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(
|
|
196
|
+
onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${pin} onFocusIn=${focus}
|
|
197
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(pinned())}>
|
|
179
198
|
<${Chart} scale=${X} cross=${Y} crossTicks=${[50, 60, 70, 80, 90]} height=${340}
|
|
180
199
|
label="Life expectancy at birth against GDP per person, twenty countries, 2023"
|
|
181
200
|
theme=${{ font: 'Manrope, "Helvetica Neue", Arial, sans-serif', ink: "#191c1f", muted: "#686b70", grid: "#dfdbd0" }}>
|
|
@@ -0,0 +1,354 @@
|
|
|
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 world's tallest buildings</title>
|
|
7
|
+
<meta name="description" content="Silhouettes: each item drawn in its own outline (a shape() per row on a vertical Bar) to one scale on one street, its height on its top, the picked one's level carried across to the tallest with a dimension line, names on the street on wide screens and ranks with a key on narrow ones. Use it for things a reader knows by their shape: buildings, mountains, ships, bottles, animals.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: an outline per row built from measured profiles with shape(), drawn to one scale on one ground line; a dashed level from the picked item's top across to the leader, with a dimension line for the gap; a slat type per width (names on the street, or ranks with a key below); and a readout of the picked item.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (the ten tallest buildings as a skyline on one street under a pale blue sky, the headline set in the sky over the lowest towers, the street a dark band holding the names, the readout and the note).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
14
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
15
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
16
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=League+Gothic&family=Barlow:wght@400;500;600;700&display=swap">
|
|
17
|
+
<script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
|
|
18
|
+
<style>
|
|
19
|
+
/* Look: the sky over one imaginary street. A pale daytime sky for the paper, the towers in its own haze,
|
|
20
|
+
the one in focus in deep navy, the street a navy ground, and a surveyor's red for the measurement. */
|
|
21
|
+
:root {
|
|
22
|
+
--page: #9fb9d4; --sky: #c4d9ee; --paper: #dbe8f5; --ink: #0f2540; --soft: #3d5878; --rule: #b7cde2;
|
|
23
|
+
--accent: #c8102e; --quiet: #5b789c;
|
|
24
|
+
--ground: #0f2540; --on-ground: #e4edf7; --ground-soft: #a9bdd6;
|
|
25
|
+
--display: "League Gothic", "Arial Narrow", Impact, sans-serif;
|
|
26
|
+
--text: Barlow, "Helvetica Neue", Arial, sans-serif;
|
|
27
|
+
--pad: 60px;
|
|
28
|
+
}
|
|
29
|
+
* { box-sizing: border-box; }
|
|
30
|
+
body { margin: 0; padding: 48px 16px; background: var(--page, #9fb9d4); }
|
|
31
|
+
|
|
32
|
+
/* The poster: sky on top, the street at the foot. Every part is a grid item, placed per width below. */
|
|
33
|
+
.poster { display: grid; grid-template-columns: minmax(0, 1fr); max-width: 1120px; margin: 0 auto; padding: var(--pad) var(--pad) 0; overflow: hidden;
|
|
34
|
+
color: var(--ink); font: 16px/1.5 var(--text); background: linear-gradient(var(--sky), var(--paper) 72%);
|
|
35
|
+
box-shadow: 0 30px 60px -34px rgb(15 37 64 / .6);
|
|
36
|
+
grid-template-areas: "kicker" "headline" "dek" "stage" "readout" "key" "note"; }
|
|
37
|
+
.poster figcaption { display: contents; }
|
|
38
|
+
.poster .kicker { grid-area: kicker; margin-bottom: 12px; font: 700 13px/1.3 var(--text); letter-spacing: .16em; text-transform: uppercase; color: var(--ink); }
|
|
39
|
+
.poster .kicker::before { content: ""; display: inline-block; width: 9px; height: 9px; margin-right: 10px; border-radius: 50%; background: var(--accent); vertical-align: 1px; }
|
|
40
|
+
.poster .headline { grid-area: headline; max-width: 13em; font: 400 clamp(44px, 5.6vw, 66px)/.95 var(--display); letter-spacing: .005em; text-wrap: balance; }
|
|
41
|
+
.poster .dek { grid-area: dek; max-width: 46ch; margin-top: 16px; font-size: 17px; line-height: 1.45; color: var(--soft); }
|
|
42
|
+
.poster .stage { grid-area: stage; position: relative; min-width: 0; margin-top: 18px; }
|
|
43
|
+
/* The street: a navy ground under the towers, from edge to edge of the poster, as tall as the room for the names */
|
|
44
|
+
.poster .stage::before { content: ""; position: absolute; left: calc(-1 * var(--pad)); right: calc(-1 * var(--pad)); bottom: 0; height: var(--street); background: var(--ground); }
|
|
45
|
+
/* Everything after the chart stands on the street */
|
|
46
|
+
.poster .readout, .poster .key, .poster .note { margin-inline: calc(-1 * var(--pad)); padding-inline: var(--pad); background: var(--ground); color: var(--on-ground); }
|
|
47
|
+
/* Every version of the readout in one grid cell, only the current one shown: it is as tall as the longest at every
|
|
48
|
+
width, so nothing under it moves whichever tower is picked */
|
|
49
|
+
.poster .readout { grid-area: readout; display: grid; margin-top: 0; margin-bottom: 0; padding-top: 8px; padding-bottom: 10px; font-size: 17px; line-height: 1.4; font-variant-numeric: tabular-nums; }
|
|
50
|
+
.poster .readout b { font-weight: 700; }
|
|
51
|
+
.poster .readout > .v { grid-area: 1 / 1; visibility: hidden; }
|
|
52
|
+
.poster .readout > .v.on { visibility: visible; }
|
|
53
|
+
.poster .readout .v span { display: block; color: var(--ground-soft); }
|
|
54
|
+
.poster .key { grid-area: key; margin-block: 0; padding-block: 6px 4px; list-style: none; font-size: 15px; line-height: 1.35; }
|
|
55
|
+
.poster .key li { display: grid; grid-template-columns: 1.6em minmax(0, 1fr) auto; column-gap: 8px; padding: 5px 0; border-top: 1px solid rgb(255 255 255 / .12); cursor: default; }
|
|
56
|
+
.poster .key li b { font: 400 19px/1 var(--display); color: var(--ground-soft); }
|
|
57
|
+
.poster .key li em { font-style: normal; color: var(--ground-soft); }
|
|
58
|
+
.poster .key li span { font-variant-numeric: tabular-nums; }
|
|
59
|
+
/* The picked tower's line is marked by a band, not by weight: bold text would wrap and move what is under it */
|
|
60
|
+
.poster .key li.lit { background: rgb(255 255 255 / .1); box-shadow: -8px 0 0 rgb(255 255 255 / .1), 8px 0 0 rgb(255 255 255 / .1); }
|
|
61
|
+
.poster .key li.lit b { color: var(--on-ground); }
|
|
62
|
+
.poster .note { grid-area: note; display: block; padding-top: 12px; padding-bottom: 26px; border-top: 1px solid rgb(255 255 255 / .16); font-size: 12.5px; line-height: 1.5; color: var(--ground-soft); }
|
|
63
|
+
.poster :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
|
64
|
+
|
|
65
|
+
/* Wide screens: the kicker and the headline float in the sky over the five lowest towers, the dek over the next three.
|
|
66
|
+
The columns follow the towers: the plot is the whole stage, one tenth per tower.
|
|
67
|
+
This place suits these heights: with other data, check that the words still clear the tips (pitfalls.md, "Text and
|
|
68
|
+
layers laid over the chart"). */
|
|
69
|
+
@media (min-width: 980px) {
|
|
70
|
+
.poster { grid-template-columns: calc(100% * .5) calc(100% * .3) minmax(0, 1fr); grid-template-rows: auto auto 1fr auto auto;
|
|
71
|
+
grid-template-areas: "kicker dek ." "headline dek ." ". . ." "readout readout readout" "note note note"; }
|
|
72
|
+
.poster .headline, .poster .kicker, .poster .dek { position: relative; z-index: 2; }
|
|
73
|
+
.poster .headline { padding-right: 28px; }
|
|
74
|
+
.poster .dek { align-self: start; margin-top: 2px; padding-right: 20px; font-size: 16px; }
|
|
75
|
+
.poster .stage { grid-area: 1 / 1 / 4 / 4; align-self: end; margin-top: 32px; }
|
|
76
|
+
.poster .key { display: none; }
|
|
77
|
+
}
|
|
78
|
+
@media (max-width: 640px) {
|
|
79
|
+
body { padding: 16px 16px 32px; }
|
|
80
|
+
:root { --pad: 16px; }
|
|
81
|
+
.poster { padding-top: 24px; }
|
|
82
|
+
.poster .kicker { font-size: 12px; margin-bottom: 10px; }
|
|
83
|
+
.poster .dek { margin-top: 12px; font-size: 15px; }
|
|
84
|
+
.poster .stage { margin-top: 14px; }
|
|
85
|
+
.poster .readout { font-size: 15px; }
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
88
|
+
</head>
|
|
89
|
+
<body>
|
|
90
|
+
<div id="chart"></div>
|
|
91
|
+
<script type="module">
|
|
92
|
+
import { Chart, Plot, Bar, Tick, Label, Place, Poster, Show, slat, shape, nice, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
93
|
+
|
|
94
|
+
// Data: the ten tallest completed buildings in the world, height to the architectural top in metres
|
|
95
|
+
// (spires count, antennas do not), from the Council on Tall Buildings and Urban Habitat, October 2026.
|
|
96
|
+
const BUILDINGS = [
|
|
97
|
+
{ name: "Burj Khalifa", city: "Dubai", year: 2010, floors: 163, height: 828 },
|
|
98
|
+
{ name: "Menara Merdeka Maybank", formerly: "Merdeka 118", city: "Kuala Lumpur", year: 2023, floors: 118, height: 679 },
|
|
99
|
+
{ name: "Shanghai Tower", city: "Shanghai", year: 2015, floors: 128, height: 632 },
|
|
100
|
+
{ name: "Makkah Royal Clock Tower", city: "Mecca", year: 2012, floors: 120, height: 601, clock: { at: 428, size: 43 } }, // its faces: 43 m across, their middle 428 m up
|
|
101
|
+
{ name: "Ping An Finance Center", city: "Shenzhen", year: 2017, floors: 115, height: 599 },
|
|
102
|
+
{ name: "Lotte World Tower", city: "Seoul", year: 2017, floors: 123, height: 555 },
|
|
103
|
+
{ name: "One World Trade Center", city: "New York", year: 2014, floors: 94, height: 541 },
|
|
104
|
+
{ name: "Guangzhou CTF Finance Centre", city: "Guangzhou", year: 2016, floors: 111, height: 530 },
|
|
105
|
+
{ name: "Tianjin CTF Finance Centre", city: "Tianjin", year: 2019, floors: 97, height: 530 },
|
|
106
|
+
{ name: "CITIC Tower", city: "Beijing", year: 2018, floors: 109, height: 528 },
|
|
107
|
+
];
|
|
108
|
+
const AS_OF = 2026;
|
|
109
|
+
const SOURCE = "Source: Council on Tall Buildings and Urban Habitat (CTBUH), The Skyscraper Center, completed buildings, October 2026. "
|
|
110
|
+
+ "Height to the architectural top: spires count, antennas do not. Merdeka 118 was renamed Menara Merdeka Maybank in July 2026. "
|
|
111
|
+
+ "Outlines are simplified side views, all drawn to one scale.";
|
|
112
|
+
|
|
113
|
+
// Outlines: each building seen from the side, as [metres above the street, metres from its axis to its edge] from the
|
|
114
|
+
// street up; the left side, and the right side when it differs (an edge past the axis has a negative distance).
|
|
115
|
+
// Measured every 2 m from Phoenix CZE's to-scale elevation diagram, "Tallest buildings 2026" (Wikimedia Commons,
|
|
116
|
+
// CC BY-SA 4.0), and simplified to within a metre; podiums are left out, and the Clock Tower's crescent, too small to draw,
|
|
117
|
+
// ends its spire in a point. Every outline is drawn on one scale, BOX metres wide.
|
|
118
|
+
const PROFILE = {
|
|
119
|
+
"Burj Khalifa": {
|
|
120
|
+
left: [[0, 47], [125, 47], [126, 38], [223, 38], [224, 29.5], [349, 28.5], [350, 20], [504, 20], [506, 14.5], [568, 14.5], [570, 11], [604, 11], [606, 8.5], [740, 7], [742, 4], [776, 4], [778, 2.5], [790, 2.5], [792, 1], [828, 1]],
|
|
121
|
+
right: [[0, 47.5], [99, 47.5], [100, 39], [191, 39], [192, 30], [345, 30], [346, 21.5], [442, 21.5], [444, 16], [526, 16], [528, 12.5], [586, 12.5], [588, 9.5], [644, 9.5], [646, 7], [694, 6.5], [696, 5], [756, 4], [758, 2.5], [790, 2.5], [792, 1], [828, 1]],
|
|
122
|
+
},
|
|
123
|
+
"Menara Merdeka Maybank": {
|
|
124
|
+
left: [[0, -4.5], [76, 6], [152, -2], [222, 6], [294, -1], [310, -1], [346, 3.5], [380, 6], [452, -2], [566, 10], [568, 4.5], [584, 3], [676, 3], [679, 1.5]],
|
|
125
|
+
right: [[0, 81.5], [106, 81.5], [296, 61.5], [380, 68.5], [528, 53.5], [553, 53.5], [554, 7.5], [556, 5], [594, 3], [676, 3], [679, 1.5]],
|
|
126
|
+
},
|
|
127
|
+
"Shanghai Tower": {
|
|
128
|
+
left: [[0, 36.5], [38, 36.5], [92, 39.5], [178, 39.5], [374, 24.5], [448, 21.5], [632, 21.5]],
|
|
129
|
+
right: [[0, 45.5], [38, 45.5], [96, 39.5], [388, 39.5], [396, 38], [480, 35.5], [536, 31], [588, 24.5], [612, 19], [616, 14.5], [618, 9.5], [620, 7], [632, 7]],
|
|
130
|
+
},
|
|
131
|
+
"Makkah Royal Clock Tower": {
|
|
132
|
+
left: [[0, 47], [357, 47.5], [358, 37.5], [397, 41], [398, 34.5], [402, 34.5], [404, 33], [452, 33], [458, 30], [463, 30], [464, 21], [469, 21], [470, 12.5], [472, 10.5], [486, 13], [490, 11.5], [496, 6], [558, 4.5], [601, 0.6]],
|
|
133
|
+
right: [[0, 48], [357, 48], [358, 37.5], [397, 41], [398, 34.5], [402, 34.5], [404, 33], [452, 33], [458, 30], [460, 30.5], [465, 28], [466, 21], [469, 21], [470, 11.5], [474, 10.5], [486, 13], [492, 10], [496, 6], [558, 4.5], [601, 0.6]],
|
|
134
|
+
},
|
|
135
|
+
"Ping An Finance Center": {
|
|
136
|
+
left: [[0, 41.5], [84, 39], [404, 39], [416, 37.5], [538, 32.5], [550, 31], [599, 0.6]],
|
|
137
|
+
right: [[0, 39.5], [402, 39], [404, 37.5], [412, 36.5], [540, 31], [550, 29.5], [599, 0.6]],
|
|
138
|
+
},
|
|
139
|
+
"Lotte World Tower": {
|
|
140
|
+
left: [[0, 44.5], [138, 44.5], [256, 37.5], [555, 10.5]],
|
|
141
|
+
right: [[0, 45.5], [140, 44.5], [254, 38], [555, 8.5]],
|
|
142
|
+
},
|
|
143
|
+
"One World Trade Center": {
|
|
144
|
+
left: [[0, 31.5], [413, 31.5], [414, 17], [424, 17], [426, 12.5], [452, 6], [464, 1], [541, 1]],
|
|
145
|
+
right: [[0, 31.5], [413, 31.5], [414, 17], [424, 17], [426, 11.5], [446, 7.5], [464, 1], [541, 1]],
|
|
146
|
+
},
|
|
147
|
+
"Guangzhou CTF Finance Centre": {
|
|
148
|
+
left: [[0, 39.5], [530, 39.5]],
|
|
149
|
+
right: [[0, 39.5], [437, 39.5], [438, 24], [500, 24], [508, 8.5], [512, -3], [526, -35.5], [530, -35.5]],
|
|
150
|
+
},
|
|
151
|
+
"Tianjin CTF Finance Centre": {
|
|
152
|
+
// its crown ends in two rounded lobes with a dip between them
|
|
153
|
+
left: [[0, 38], [130, 38], [228, 29.5], [436, 23], [518, 15], [524, 12], [528, 9], [530, 6], [529, 3.5], [525, 0.3]],
|
|
154
|
+
right: [[0, 38], [130, 38], [216, 29.5], [308, 27], [328, 28], [438, 23], [518, 14.5], [524, 11], [528, 8.5], [530, 6], [529, 3.5], [525, 0.3]],
|
|
155
|
+
},
|
|
156
|
+
"CITIC Tower": {
|
|
157
|
+
left: [[0, 43.5], [32, 43.5], [108, 38], [138, 38], [140, 36.5], [150, 35.5], [264, 31], [460, 31], [520, 36.5], [528, 35.5]],
|
|
158
|
+
right: [[0, 44.5], [30, 44.5], [46, 42], [140, 35.5], [250, 32], [470, 32], [518, 35.5], [528, 35]],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const BOX = 165;
|
|
162
|
+
// A profile as a Bar's outline: along runs from the street (0) to the top (1), across from the left (0) to the right (1);
|
|
163
|
+
// shift moves the building's axis across its slat, in metres
|
|
164
|
+
const outline = ({ left, right = left }, shift = 0) => {
|
|
165
|
+
const top = Math.max(...left.map(([h]) => h), ...right.map(([h]) => h));
|
|
166
|
+
return shape(
|
|
167
|
+
...left.map(([h, w], i) => [i ? "L" : "M", h / top, 0.5 + (shift - w) / BOX]),
|
|
168
|
+
...[...right].reverse().map(([h, w]) => ["L", h / top, 0.5 + (shift + w) / BOX]),
|
|
169
|
+
["Z"],
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// What depends on the data: ranks, places on the street (lowest on the left), the leader and the runner-up
|
|
174
|
+
const HEIGHT = BUILDINGS.map((b) => b.height);
|
|
175
|
+
const scale = { min: 0, max: Math.ceil(Math.max(...HEIGHT) / 10) * 10 }; // 0 to 830 m: the top of the plot is just over the tallest tip
|
|
176
|
+
const byHeight = BUILDINGS.map((b, i) => i).sort((a, b) => HEIGHT[b] - HEIGHT[a]); // tallest first
|
|
177
|
+
const LEAD = byHeight[0];
|
|
178
|
+
const SECOND = byHeight[1];
|
|
179
|
+
const RANK = HEIGHT.map((h) => 1 + HEIGHT.filter((other) => other > h).length); // ties share a rank, as in CTBUH's table
|
|
180
|
+
const POS = BUILDINGS.map((b, i) => BUILDINGS.length - 1 - byHeight.indexOf(i)); // place on screen, lowest first
|
|
181
|
+
const profileOf = (b) => PROFILE[b.name] ?? { left: [[0, 30], [b.height, 30]] };
|
|
182
|
+
// Even gaps: every building's widest extent is laid along the street with the same space between each, and each axis
|
|
183
|
+
// moves across its slat to where that puts it (SHIFT, a share of the slat, which the labels and lines follow)
|
|
184
|
+
const EXTENT = BUILDINGS.map((b) => {
|
|
185
|
+
const { left, right = left } = profileOf(b);
|
|
186
|
+
return [Math.max(...left.map(([, w]) => w)), Math.max(...right.map(([, w]) => w))];
|
|
187
|
+
});
|
|
188
|
+
const SHIFT = new Array(BUILDINGS.length);
|
|
189
|
+
{
|
|
190
|
+
const placed = POS.map((p, i) => [p, i]).sort((a, b) => a[0] - b[0]).map(([, i]) => i); // left to right
|
|
191
|
+
const space = (BUILDINGS.length * BOX - EXTENT.reduce((sum, [l, r]) => sum + l + r, 0)) / BUILDINGS.length;
|
|
192
|
+
let x = space / 2;
|
|
193
|
+
placed.forEach((i, place) => {
|
|
194
|
+
SHIFT[i] = (x + EXTENT[i][0] - BOX * (place + 0.5)) / BOX;
|
|
195
|
+
x += EXTENT[i][0] + EXTENT[i][1] + space;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const OUTLINE = BUILDINGS.map((b, i) => outline(profileOf(b), SHIFT[i] * BOX));
|
|
199
|
+
const WORDS = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve",
|
|
200
|
+
"thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty"];
|
|
201
|
+
const word = (n) => WORDS[n] ?? String(n);
|
|
202
|
+
const cap = (s) => s[0].toUpperCase() + s.slice(1);
|
|
203
|
+
const metres = (m) => `${m} m`;
|
|
204
|
+
const fullName = (i) => BUILDINGS[i].name + (BUILDINGS[i].formerly ? ` (formerly ${BUILDINGS[i].formerly})` : "");
|
|
205
|
+
const lead = BUILDINGS[LEAD];
|
|
206
|
+
const margin = lead.height - BUILDINGS[SECOND].height;
|
|
207
|
+
const cities = new Set(BUILDINGS.map((b) => b.city)).size;
|
|
208
|
+
// The tower the picked one is measured against the leader with: itself, or the runner-up when the leader is picked
|
|
209
|
+
const against = (i) => (i === LEAD ? SECOND : i);
|
|
210
|
+
const compare = (i) => (i === LEAD
|
|
211
|
+
? `${metres(margin)} taller than ${BUILDINGS[SECOND].name}`
|
|
212
|
+
: `${metres(lead.height - HEIGHT[i])} shorter than the ${lead.name}`);
|
|
213
|
+
const facts = (i) => `${metres(HEIGHT[i])}, ${BUILDINGS[i].floors} floors, finished ${BUILDINGS[i].year}`;
|
|
214
|
+
const say = (i) => `${RANK[i]}. ${fullName(i)}, ${BUILDINGS[i].city}: ${facts(i)}; ${compare(i)}.`;
|
|
215
|
+
|
|
216
|
+
// Slat type: one tower in its own outline, its height over its top, its name on the street. The level of the tower the
|
|
217
|
+
// reader picked runs from its top across to the leader, where a red dimension line measures what is left above it.
|
|
218
|
+
const TOWER_CSS = `
|
|
219
|
+
.tower { cursor: default; }
|
|
220
|
+
/* Focus rings the name on the street: a ring round the whole tower would cross the headline set in the sky */
|
|
221
|
+
.tower:focus-visible { outline: none; }
|
|
222
|
+
.tower:focus-visible .name { outline: 2px solid var(--rhp-surface); outline-offset: 3px; }
|
|
223
|
+
.tip { --rhp-label-gap: 5px; font: 400 21px/1 "League Gothic", "Arial Narrow", sans-serif; letter-spacing: .02em; color: var(--rhp-muted); }
|
|
224
|
+
.lit .tip { color: var(--rhp-ink); font-size: 25px; }
|
|
225
|
+
.level { --rhp-tick-width: 2px; background: repeating-linear-gradient(90deg, var(--rhp-ink) 0 5px, transparent 5px 9px); }
|
|
226
|
+
.level.none { visibility: hidden; }
|
|
227
|
+
.level.first { translate: 0 50%; }
|
|
228
|
+
.level.last { translate: -100% 50%; }
|
|
229
|
+
.dim { translate: var(--dx) 0; }
|
|
230
|
+
/* A clock face: a ring cut out of the tower, round whatever the chart's proportions, in the sky's color */
|
|
231
|
+
.clock { position: absolute; left: calc(50% + var(--shift) * 100%); bottom: calc(var(--at) * 100%); width: calc(var(--size) * 100%); aspect-ratio: 1;
|
|
232
|
+
translate: -50% 50%; border: 2px solid #d0e1f2; border-radius: 50%; }
|
|
233
|
+
/* The labels and lines follow the building's axis, moved across the slat by --shift for even gaps */
|
|
234
|
+
.tip, .dim, .cap, .mark, .level.first, .level.last { margin-left: calc(var(--shift) * 100%); }
|
|
235
|
+
.cap { translate: 0 50%; }
|
|
236
|
+
.gap { position: absolute; left: calc(var(--dx) + 7px); top: 0; translate: 0 -50%; font: 400 36px/1 "League Gothic", "Arial Narrow", sans-serif; color: var(--rhp-series-1); white-space: nowrap; }
|
|
237
|
+
.name { --rhp-label-gap: 10px; white-space: normal; text-align: center; line-height: 1.15; font-size: 12.5px; color: var(--rhp-surface); }
|
|
238
|
+
.name b { display: block; padding-bottom: 4px; font-weight: 600; }
|
|
239
|
+
.name i, .name small { display: block; font-style: normal; font-size: 11.5px; color: color-mix(in oklab, var(--rhp-surface) 72%, var(--rhp-ink)); }
|
|
240
|
+
.lit .name b { font-weight: 700; text-decoration: underline 2px; text-underline-offset: 2px; }
|
|
241
|
+
.rank { font: 400 19px/1 "League Gothic", "Arial Narrow", sans-serif; color: color-mix(in oklab, var(--rhp-surface) 72%, var(--rhp-ink)); }
|
|
242
|
+
.lit .rank { color: var(--rhp-surface); }
|
|
243
|
+
@container (max-width: 640px) {
|
|
244
|
+
.tip { font-size: 16px; }
|
|
245
|
+
.lit .tip { font-size: 19px; }
|
|
246
|
+
.tip .u { display: none; }
|
|
247
|
+
.gap { font-size: 26px; }
|
|
248
|
+
.clock { border-width: 1px; }
|
|
249
|
+
}
|
|
250
|
+
`;
|
|
251
|
+
const tower = (d) => html`
|
|
252
|
+
<div class=${() => (d.lit ? "tower lit" : "tower")} data-row=${() => d.index} aria-label=${() => say(d.index)}
|
|
253
|
+
style=${() => `--dx: ${d.wide ? 14 : 8}px; --shift: ${d.shift}`}>
|
|
254
|
+
<${Bar} to=${() => d.height} thick=${1} shape=${() => d.outline} color=${() => (d.lit ? "ink" : "series-2")}>
|
|
255
|
+
<${Show} when=${() => d.clock}>
|
|
256
|
+
<i class="clock" aria-hidden="true" style=${() => `--at: ${d.clock.at / d.height}; --size: ${d.clock.size / BOX}`}></i>
|
|
257
|
+
<//>
|
|
258
|
+
<//>
|
|
259
|
+
<${Tick} at=${() => d.level} thick=${() => ({ mid: 1, first: 0.5 - d.shift, last: 0.5 + d.shift })[d.span] ?? 0.5} class=${() => "level " + d.span} />
|
|
260
|
+
<${Label} at=${() => d.height} class="tip">${() => d.height}<span class="u"> m</span><//>
|
|
261
|
+
<${Label} edge="start" class="name">
|
|
262
|
+
<${Show} when=${() => d.wide} fallback=${html`<b class="rank">${() => d.rank}</b>`}>
|
|
263
|
+
<b>${() => d.name}</b>
|
|
264
|
+
<${Show} when=${() => d.formerly}><i>(${() => d.formerly})</i><//>
|
|
265
|
+
<small>${() => d.city}</small>
|
|
266
|
+
<//>
|
|
267
|
+
<//>
|
|
268
|
+
<${Show} when=${() => d.lead}>
|
|
269
|
+
<${Bar} from=${() => d.level} to=${() => d.height} thick="2px" color="series-1" class="dim" />
|
|
270
|
+
<${Tick} at=${() => d.level} thick=${() => `${d.wide ? 22 : 14}px`} color="series-1" class="cap" />
|
|
271
|
+
<${Tick} at=${() => d.height} thick=${() => `${d.wide ? 22 : 14}px`} color="series-1" class="cap" />
|
|
272
|
+
<${Place} at=${() => (d.level + d.height) / 2} class="mark"><b class="gap">${() => metres(d.height - d.level)}</b><//>
|
|
273
|
+
<//>
|
|
274
|
+
</div>`;
|
|
275
|
+
// The same tower with the room each width needs: names on the street on wide screens, ranks on narrow ones
|
|
276
|
+
const TowerNamed = slat({ room: { start: 88, end: 32, after: 32 }, css: TOWER_CSS }, tower);
|
|
277
|
+
const TowerRanked = slat({ room: { start: 32, end: 32, after: 46 }, css: TOWER_CSS }, tower);
|
|
278
|
+
|
|
279
|
+
// The chart's font and colors, read from the page's tokens
|
|
280
|
+
const THEME = {
|
|
281
|
+
font: "var(--text)", ink: "var(--ink)", muted: "var(--soft)", grid: "var(--rule)", surface: "var(--on-ground)",
|
|
282
|
+
series: ["var(--accent)", "var(--quiet)"],
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Chart component: the tower the reader points at, taps or reaches with the keyboard is lit and measured against the leader
|
|
286
|
+
function Skyline() {
|
|
287
|
+
const media = matchMedia("(min-width: 980px)");
|
|
288
|
+
const [wide, setWide] = createSignal(media.matches);
|
|
289
|
+
media.addEventListener("change", (e) => setWide(e.matches));
|
|
290
|
+
const [picked, setPicked] = createSignal(null);
|
|
291
|
+
const lit = () => picked() ?? LEAD;
|
|
292
|
+
const ref = () => against(lit());
|
|
293
|
+
// Where the level line runs: half of the measured tower's band (first), every band between (mid), half of the leader's (last)
|
|
294
|
+
const span = (i) => {
|
|
295
|
+
const p = POS[i], a = POS[ref()], b = POS[LEAD];
|
|
296
|
+
return p === a ? "first" : p === b ? "last" : p > a && p < b ? "mid" : "none";
|
|
297
|
+
};
|
|
298
|
+
// The row of the element an event landed on (a tower, or a line of the key), or null
|
|
299
|
+
const rowOf = (el) => {
|
|
300
|
+
const root = el.closest("[data-row]");
|
|
301
|
+
return root ? +root.dataset.row : null;
|
|
302
|
+
};
|
|
303
|
+
// A mouse or a pen picks as it moves, a tap or a click picks, the keyboard picks; on no tower the pick stays
|
|
304
|
+
const pick = (e) => {
|
|
305
|
+
const row = rowOf(e.target);
|
|
306
|
+
if (row != null) setPicked(row);
|
|
307
|
+
};
|
|
308
|
+
// Leaving the poster: back to the tower that has focus (a keyboard's or a click's pick), or none
|
|
309
|
+
const leave = (e) => {
|
|
310
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
311
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
312
|
+
};
|
|
313
|
+
// Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
|
|
314
|
+
const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
|
|
315
|
+
const towers = (type) => html`
|
|
316
|
+
<${Plot} rows=${BUILDINGS} order=${POS} keyboard=${true} rank=${RANK} outline=${OUTLINE} shift=${SHIFT}
|
|
317
|
+
lit=${(d) => d.index === lit()} lead=${(d) => d.index === LEAD} span=${(d) => span(d.index)}
|
|
318
|
+
level=${() => HEIGHT[ref()]} wide=${wide}>${type}<//>`;
|
|
319
|
+
return html`
|
|
320
|
+
<${Poster}
|
|
321
|
+
kicker=${`The world's tallest buildings, ${AS_OF}`}
|
|
322
|
+
title=${`${cap(word(AS_OF - lead.year))} years on, the ${lead.name} still tops the rest by ${metres(margin)}`}
|
|
323
|
+
dek=${`The ${word(BUILDINGS.length)} tallest finished buildings on Earth, from ${word(cities)} cities, share one street here, each in its own outline and all at one scale. Heights in metres.`}
|
|
324
|
+
note=${SOURCE}
|
|
325
|
+
onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
|
|
326
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
327
|
+
<div class="stage" style=${() => `--street: ${wide() ? 88 : 32}px`}>
|
|
328
|
+
<${Chart} orientation="vertical" height=${() => (wide() ? 680 : 420)} scale=${[scale.min, scale.max]} ticks=${false}
|
|
329
|
+
label=${`The ${word(BUILDINGS.length)} tallest completed buildings in the world, by height in metres`} theme=${THEME}>
|
|
330
|
+
<${Show} when=${wide} fallback=${() => towers(TowerRanked)}>${() => towers(TowerNamed)}<//>
|
|
331
|
+
<//>
|
|
332
|
+
</div>
|
|
333
|
+
<p class="readout" aria-hidden="true">
|
|
334
|
+
<span class=${() => (picked() == null ? "v on" : "v")}>Tap or point at a tower to measure it against the Burj Khalifa.</span>
|
|
335
|
+
${BUILDINGS.map((b, i) => html`
|
|
336
|
+
<span class=${() => (picked() === i ? "v on" : "v")}><b>${b.name}</b>, ${b.city}<span>${facts(i)}. ${cap(compare(i))}.</span></span>`)}
|
|
337
|
+
</p>
|
|
338
|
+
<${Show} when=${() => !wide()}>
|
|
339
|
+
<ol class="key" aria-hidden="true">
|
|
340
|
+
${byHeight.map((i) => html`
|
|
341
|
+
<li data-row=${i} class=${() => (lit() === i ? "lit" : "")}>
|
|
342
|
+
<b>${RANK[i]}</b>
|
|
343
|
+
<div>${fullName(i)}<em>, ${BUILDINGS[i].city}</em></div>
|
|
344
|
+
<span>${metres(HEIGHT[i])}</span>
|
|
345
|
+
</li>`)}
|
|
346
|
+
</ol>
|
|
347
|
+
<//>
|
|
348
|
+
<//>`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
render(() => html`<${Skyline} />`, document.getElementById("chart"));
|
|
352
|
+
</script>
|
|
353
|
+
</body>
|
|
354
|
+
</html>
|
package/skill/recipes/slope.html
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Eggs doubled</title>
|
|
7
7
|
<meta name="description" content="Slope chart: each item's value at two dates joined by a line on a second axis, labels kept apart on both sides, the change written beside each item, and the line nearest the pointer, a tap or the arrow keys picked out. Use it to compare change between two dates across items.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: a Line of two points per item on a second axis, labels kept apart on both sides, and the line nearest the pointer picked out.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (the price of eggs as a till receipt, torn at the top and the bottom, a barcode at its foot).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
16
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Azeret+Mono:wght@400..800&display=swap">
|
|
@@ -132,6 +138,7 @@ const Item = slat({
|
|
|
132
138
|
// picked out and the others fade
|
|
133
139
|
function GroceryChart() {
|
|
134
140
|
const [picked, setPicked] = createSignal(null);
|
|
141
|
+
const [pinned, setPinned] = createSignal(null); // the item a click or a tap picked: the chart comes back to it
|
|
135
142
|
let plot;
|
|
136
143
|
const nearest = (e) => {
|
|
137
144
|
const r = plot.getBoundingClientRect();
|
|
@@ -158,24 +165,36 @@ function GroceryChart() {
|
|
|
158
165
|
const i = nearest(e);
|
|
159
166
|
if (i != null) setPicked(i);
|
|
160
167
|
};
|
|
168
|
+
// A tap or a click picks it too, and pins it
|
|
169
|
+
const pin = (e) => {
|
|
170
|
+
point(e);
|
|
171
|
+
setPinned(picked());
|
|
172
|
+
};
|
|
161
173
|
// The keyboard picks the item it focuses
|
|
162
174
|
const focus = (e) => {
|
|
163
175
|
const i = rowOf(e.target);
|
|
164
176
|
if (i != null) setPicked(i);
|
|
165
177
|
};
|
|
166
|
-
// Leaving the chart: back to the item that has focus (the keyboard's pick),
|
|
178
|
+
// Leaving the chart: back to the item that has focus (the keyboard's pick), else the pinned one, else none
|
|
167
179
|
const leave = (e) => {
|
|
168
180
|
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
169
|
-
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) :
|
|
181
|
+
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : pinned());
|
|
182
|
+
};
|
|
183
|
+
// Clicking poster text or margin resets this existing pick to the chart's original default.
|
|
184
|
+
const reset = (e) => {
|
|
185
|
+
if (e.target.closest(".plot, a, button, input, select, textarea, label, [role=button], [role=slider]")) return;
|
|
186
|
+
setPinned(null);
|
|
187
|
+
setPicked(null);
|
|
170
188
|
};
|
|
171
189
|
return html`
|
|
172
190
|
<${Poster}
|
|
191
|
+
onClick=${reset}
|
|
173
192
|
kicker="Price check, US city average"
|
|
174
193
|
title="A dozen eggs cost twice as much in 2024 as in 2020"
|
|
175
194
|
dek="Six staples, each at its average price in 2020 and in 2024. Coffee went up 43%, bread 36%, beef 31% and milk 20%; bananas went up 4 cents."
|
|
176
195
|
note="Source: US Bureau of Labor Statistics, average price data for 2020 and 2024 (the mean of each year's twelve monthly prices). Point at a line, tap it, or Tab into the chart and use the arrow keys.">
|
|
177
|
-
<div class="plot" onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${
|
|
178
|
-
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(
|
|
196
|
+
<div class="plot" onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${pin} onFocusIn=${focus}
|
|
197
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(pinned())}>
|
|
179
198
|
<${Chart} scale=${[0, 1]} cross=${[0, TOP]} crossTicks=${false} height=${HEIGHT}
|
|
180
199
|
label=${`Average US prices of six groceries in ${YEARS[0]} and ${YEARS[1]}`}
|
|
181
200
|
theme=${{ font: '"Azeret Mono", ui-monospace, Menlo, monospace', ink: "#1d1d1b", muted: "#55534e" }}>
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Richer since 1990</title>
|
|
7
7
|
<meta name="description" content="Sparkline table: one small line per item, each scaled to its own peak, with the start level dashed, and a scrub through the years (pointer, tap, a sideways drag or arrow keys) that moves a dot along every line and updates each item's value and its change since the start. Use it for many series side by side, a table of trends.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: a Line in each slat scaled to itself, the start level dashed, numbers in columns, and a year scrub that moves a dot along every line.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (incomes since 1990 as a table page of a statistical yearbook, cream paper between a thick and a thin rule).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
16
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Libre+Caslon+Text:wght@400;700&family=Libre+Franklin:wght@400..800&display=swap">
|
|
@@ -105,7 +111,7 @@ const Country = slat({
|
|
|
105
111
|
|
|
106
112
|
// The year being read: a hairline down every slat
|
|
107
113
|
const Crosshair = slat({ room: {}, css: `.hair { --rhp-tick-width: 1px; background: #c2410c; opacity: .55; }` }, (d) => html`
|
|
108
|
-
|
|
114
|
+
<${Tick} at=${() => d.year} thick=${1} class="hair" />`);
|
|
109
115
|
|
|
110
116
|
// Chart component: hovering previews a year; a click, tap, sideways drag or the arrow keys saves it for every slat.
|
|
111
117
|
// With no saved year, the latest shows. The year in the table's head is a slider for the keyboard and screen readers.
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>How cities get to work</title>
|
|
7
7
|
<meta name="description" content="100% stacked bar chart of shares per category, labeled where they fit, with a key that lights one part in every bar on hover, focus and tap. Use it to compare how a whole splits across several categories.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: stackUp() of shares() in an overlap inner Plot, labels inside where they fit, and a key that lights one part in every bar.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (ways to commute as a transit map: enamel white, geometric type, the key drawn as a metro line whose stations are the buttons).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
16
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Jost:wght@400..800&display=swap">
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Europe's power mix</title>
|
|
7
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
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: stackUp() parts in an overlap inner Plot, and a key that moves one part to the start of every bar and ranks by it.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (Europe's power mix on drafting paper, a line of pylons under the headline).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
16
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Oswald:wght@500..700&family=Public+Sans:wght@400..800&display=swap">
|
package/skill/recipes/strip.html
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Burger places are hit or miss</title>
|
|
7
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
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: an overlap Plot of Dots inside each group's slat, a median Tick, and a readout of the dot under the pointer.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (restaurant ratings as a chalkboard menu in a wooden frame, its slate smudged where it was wiped).
|
|
12
|
+
Its look, the place of its parts and the way its marks are drawn say that subject: design yours from your own (design.md, section 2).
|
|
13
|
+
-->
|
|
8
14
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
15
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
16
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cabin+Sketch:wght@700&family=Karla:wght@400..800&display=swap">
|
|
@@ -186,13 +192,15 @@ function RestaurantRatings() {
|
|
|
186
192
|
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
187
193
|
setPicked(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
|
|
188
194
|
};
|
|
195
|
+
// Off the chart (on the headline, the readout or the note), pointing is as if the pointer had left the poster
|
|
196
|
+
const move = (e) => e.pointerType !== "touch" && (e.target.closest(".rhp-chart") ? pick(e) : leave(e));
|
|
189
197
|
return html`
|
|
190
198
|
<${Poster}
|
|
191
199
|
kicker="City food guide · ratings out of 5"
|
|
192
200
|
title="Burger places are hit or miss"
|
|
193
201
|
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
202
|
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=${
|
|
203
|
+
onPointerMove=${move} onClick=${pick} onFocusIn=${pick}
|
|
196
204
|
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setPicked(null)}>
|
|
197
205
|
<div class="readout">
|
|
198
206
|
<span class="stars">${() => one.format(place().rating)} ★</span>
|