@bezda/rhp-mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +222 -2
- package/bin/rhp-mcp.js +129 -0
- package/package.json +62 -4
- package/skill/SKILL.md +450 -0
- package/skill/recipes/area.html +156 -0
- package/skill/recipes/bar.html +145 -0
- package/skill/recipes/box-plot.html +164 -0
- package/skill/recipes/bubble.html +188 -0
- package/skill/recipes/bullet.html +138 -0
- package/skill/recipes/candlestick.html +179 -0
- package/skill/recipes/column.html +138 -0
- package/skill/recipes/dial.html +349 -0
- package/skill/recipes/diverging-bars.html +150 -0
- package/skill/recipes/donut.html +231 -0
- package/skill/recipes/dumbbell.html +137 -0
- package/skill/recipes/gantt.html +196 -0
- package/skill/recipes/grouped-bars.html +142 -0
- package/skill/recipes/heatmap.html +183 -0
- package/skill/recipes/histogram.html +199 -0
- package/skill/recipes/line.html +170 -0
- package/skill/recipes/live.html +255 -0
- package/skill/recipes/log-line.html +493 -0
- package/skill/recipes/lollipop.html +152 -0
- package/skill/recipes/multi-line.html +228 -0
- package/skill/recipes/pyramid.html +162 -0
- package/skill/recipes/race.html +278 -0
- package/skill/recipes/radial-bars.html +157 -0
- package/skill/recipes/scatter.html +199 -0
- package/skill/recipes/silhouettes.html +352 -0
- package/skill/recipes/slope.html +199 -0
- package/skill/recipes/sparklines.html +175 -0
- package/skill/recipes/stacked-100.html +158 -0
- package/skill/recipes/stacked-bars.html +150 -0
- package/skill/recipes/strip.html +221 -0
- package/skill/recipes/unit-stack.html +276 -0
- package/skill/recipes/violin.html +169 -0
- package/skill/recipes/waffle.html +165 -0
- package/skill/recipes/waterfall.html +127 -0
- package/skill/references/api.md +1684 -0
- package/skill/references/design.md +740 -0
- package/skill/references/environments.md +1577 -0
- package/skill/references/forms.md +197 -0
- package/skill/references/interaction.md +794 -0
- package/skill/references/looks.md +267 -0
- package/skill/references/pitfalls.md +202 -0
- package/src/check/README.md +176 -0
- package/src/check/browser.js +92 -0
- package/src/check/build.js +439 -0
- package/src/check/colors.js +324 -0
- package/src/check/findings.js +247 -0
- package/src/check/index.js +508 -0
- package/src/check/inpage.js +1332 -0
- package/src/check/interact.js +266 -0
- package/src/check/png.js +98 -0
- package/src/check/report.js +106 -0
- package/src/check/source.js +387 -0
- package/src/check/sourcemap.js +75 -0
- package/src/guard/note.js +30 -0
- package/src/guard/render.js +17 -0
- package/src/guard/rhp.js +612 -0
- package/src/guard/rules.js +125 -0
- package/src/server.js +266 -0
- package/src/skill.js +140 -0
|
@@ -0,0 +1,349 @@
|
|
|
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>Where the average day goes</title>
|
|
7
|
+
<meta name="description" content="Dial: the parts of one whole drawn as arcs around a 24-hour dial (a Bar per row that gives up its box to an SVG arc), labels set along the arcs where they fit, a hand that turns the short way to the part picked, a readout in the hub and a grouped key. Use it for how a day, a week or any cycle splits into parts.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: Bars that give up their box to SVG arcs around a dial, labels set along the arcs only where they fit, a Scale of hours drawn as a bezel, picking by the pointer's angle, a hand that turns the short way round, a readout in the hub, and a key grouped by kind.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (the average American day as the 24-hour dial of a watch: royal blue face, a navy bezel with its numerals and crown, a gold hand and arc for the part picked, a spec-sheet key beside it).
|
|
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=Albert+Sans:wght@400;600;700&family=Saira:wdth,wght@100..125,500..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
|
+
/* Tokens: a royal blue watch dial, a navy bezel, white printing and gold lume */
|
|
20
|
+
:root {
|
|
21
|
+
color-scheme: dark;
|
|
22
|
+
--page: #102659;
|
|
23
|
+
--paper: #1d3f8c;
|
|
24
|
+
--bezel: #0e2253;
|
|
25
|
+
--ink: #f4f6fb;
|
|
26
|
+
--soft: #c9d4ee;
|
|
27
|
+
--rule: #3a5aa6;
|
|
28
|
+
--accent: #ffd45c;
|
|
29
|
+
--quiet: #8fa5db;
|
|
30
|
+
--steel: #a9b8dc;
|
|
31
|
+
--display: "Saira", "Eurostile", "Arial Narrow", sans-serif;
|
|
32
|
+
--text: "Albert Sans", "Helvetica Neue", Arial, sans-serif;
|
|
33
|
+
}
|
|
34
|
+
* { box-sizing: border-box; }
|
|
35
|
+
body { margin: 0; padding: 48px 16px; background: var(--page, #102659); }
|
|
36
|
+
|
|
37
|
+
/* The poster: the parts are grid items, placed by the areas below */
|
|
38
|
+
.poster { display: grid; grid-template-columns: minmax(0, 1fr); row-gap: 14px; max-width: 1080px; margin: 0 auto; padding: 48px 52px 30px;
|
|
39
|
+
grid-template-areas: "kicker" "headline" "dek" "stage" "key" "note";
|
|
40
|
+
color: var(--ink); font: 16px/1.5 var(--text);
|
|
41
|
+
background: radial-gradient(110% 70% at 15% 0%, rgb(255 255 255 / .06), transparent 70%), var(--paper); }
|
|
42
|
+
.poster figcaption { display: contents; }
|
|
43
|
+
.poster .kicker { grid-area: kicker; font: 600 13px/1.3 var(--display); font-stretch: 125%; letter-spacing: .16em; text-transform: uppercase; color: var(--accent); }
|
|
44
|
+
.poster .headline { grid-area: headline; max-width: 19em; margin-bottom: 6px; font: 700 clamp(32px, 5vw, 56px)/1.02 var(--display); font-stretch: 105%; letter-spacing: -.01em; text-wrap: balance; }
|
|
45
|
+
.poster .dek { grid-area: dek; max-width: 34em; font-size: 17px; line-height: 1.5; color: var(--soft); }
|
|
46
|
+
.poster .stage { grid-area: stage; align-self: center; min-width: 0; margin-top: 10px; }
|
|
47
|
+
.poster .key { grid-area: key; min-width: 0; }
|
|
48
|
+
.poster .note { grid-area: note; display: block; margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--rule); font-size: 12px; line-height: 1.5; color: var(--soft); }
|
|
49
|
+
.poster :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
50
|
+
|
|
51
|
+
/* The watch: the chart is the dial, its element painted as the face, the minute track, the bezel and the case */
|
|
52
|
+
.face { position: relative; max-width: 580px; margin: 0 auto; }
|
|
53
|
+
.face .rhp-chart { border-radius: 50%;
|
|
54
|
+
background:
|
|
55
|
+
radial-gradient(closest-side, #22489a 0, #1c3d88 79.4%, transparent 79.6% 82.6%, var(--bezel) 82.8% 96.6%, var(--steel) 96.8% 98.8%, #7d8fbf 99%),
|
|
56
|
+
repeating-conic-gradient(from -.5deg, var(--ink) 0 1deg, transparent 0 15deg),
|
|
57
|
+
repeating-conic-gradient(from -.2deg, rgb(201 212 238 / .7) 0 .4deg, #1c3d88 0 2.5deg); }
|
|
58
|
+
/* The crown at three o'clock, knurled */
|
|
59
|
+
.face::after { content: ""; position: absolute; top: 50%; right: -9px; width: 11px; height: 9%; translate: 0 -50%; border-radius: 3px;
|
|
60
|
+
background: repeating-linear-gradient(0deg, var(--steel) 0 2px, #6f83b8 2px 4px); }
|
|
61
|
+
.hint { width: 200px; margin: 24px auto 0; font-size: 14px; text-align: center; color: var(--soft); }
|
|
62
|
+
|
|
63
|
+
/* The middle of the dial: the hand, and the activity it points at */
|
|
64
|
+
.center { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
|
65
|
+
/* The hand runs from the middle to just inside the band and turns about its foot; only its tip shows past the hub */
|
|
66
|
+
.hand { position: absolute; left: 48.6%; top: 20.6%; width: 2.8%; height: 29.4%; transform-origin: 50% 100%;
|
|
67
|
+
rotate: calc(var(--turn) * 1turn); transition: rotate .45s cubic-bezier(.25, .8, .3, 1); }
|
|
68
|
+
.hand i { position: absolute; left: 0; top: 0; width: 100%; height: 30%; background: var(--accent);
|
|
69
|
+
clip-path: polygon(50% 0, 100% 34%, 64% 34%, 64% 100%, 36% 100%, 36% 34%, 0 34%); }
|
|
70
|
+
.hub { position: absolute; inset: 29%; display: grid; place-content: center; justify-items: center; gap: 2px; border-radius: 50%; text-align: center;
|
|
71
|
+
container-type: inline-size; }
|
|
72
|
+
.hub small { max-width: 12ch; font: 600 clamp(9px, 5.4cqi, 12px)/1.25 var(--display); font-stretch: 112%; letter-spacing: .12em; text-transform: uppercase; color: var(--soft); text-wrap: balance; }
|
|
73
|
+
.hub strong { font: 600 clamp(13px, 7.6cqi, 19px)/1.2 var(--text); max-width: 15ch; text-wrap: balance; }
|
|
74
|
+
.hub b { font: 600 clamp(26px, 19cqi, 50px)/1 var(--display); font-stretch: 112%; font-variant-numeric: tabular-nums; color: var(--accent); white-space: nowrap; }
|
|
75
|
+
.hub b span { font-size: .48em; font-weight: 500; margin: 0 .12em 0 .06em; }
|
|
76
|
+
.hub em { font-style: normal; font-size: clamp(11px, 5.8cqi, 14px); line-height: 1.3; color: var(--soft); }
|
|
77
|
+
@media (prefers-reduced-motion: reduce) { .hand { transition: none; } }
|
|
78
|
+
|
|
79
|
+
/* The key: every activity, in the dial's order, under its group */
|
|
80
|
+
.key { display: grid; gap: 16px; align-content: start; }
|
|
81
|
+
.key section { display: grid; gap: 2px; }
|
|
82
|
+
.key h3 { display: flex; justify-content: space-between; gap: 12px; margin: 0 0 2px; padding-bottom: 5px; border-bottom: 1px solid var(--rule);
|
|
83
|
+
font: 600 12px/1.3 var(--display); font-stretch: 118%; letter-spacing: .12em; text-transform: uppercase; color: var(--soft); }
|
|
84
|
+
.key h3 span { font-variant-numeric: tabular-nums; letter-spacing: .04em; white-space: nowrap; }
|
|
85
|
+
.key ul { margin: 0; padding: 0; list-style: none; }
|
|
86
|
+
.key li { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: baseline; gap: 10px; padding: 3px 0; font-size: 15px; line-height: 1.35; cursor: default; }
|
|
87
|
+
.key li i { align-self: center; width: 16px; height: 7px; border-radius: 2px; background: var(--quiet); }
|
|
88
|
+
.key li b { font: 600 15px/1.35 var(--display); font-stretch: 108%; font-variant-numeric: tabular-nums; }
|
|
89
|
+
.key li.on { color: var(--accent); }
|
|
90
|
+
.key li.on i { background: var(--accent); }
|
|
91
|
+
|
|
92
|
+
/* Middle widths: the key in two columns under the dial, so each time stays near its name */
|
|
93
|
+
@media (min-width: 641px) and (max-width: 899px) {
|
|
94
|
+
.key { display: block; columns: 2; column-gap: 44px; }
|
|
95
|
+
.key section { break-inside: avoid; margin-bottom: 16px; }
|
|
96
|
+
}
|
|
97
|
+
/* Wide: the headline across the top, as on a watch advertisement; the dial below it, with the dek and the key beside it */
|
|
98
|
+
@media (min-width: 900px) {
|
|
99
|
+
.poster { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); column-gap: 56px; grid-template-rows: auto auto auto 1fr auto;
|
|
100
|
+
grid-template-areas: "kicker kicker" "headline headline" "stage dek" "stage key" "note note"; }
|
|
101
|
+
.poster .stage { margin-top: 18px; }
|
|
102
|
+
.poster .dek { margin-top: 18px; font-size: 16px; }
|
|
103
|
+
.poster .key { margin-top: 6px; }
|
|
104
|
+
.key li { padding: 2px 0; }
|
|
105
|
+
}
|
|
106
|
+
@media (max-width: 640px) {
|
|
107
|
+
body { padding: 16px 16px 32px; }
|
|
108
|
+
.poster { padding: 26px 18px 18px; }
|
|
109
|
+
.poster .kicker { font-stretch: 100%; letter-spacing: .12em; }
|
|
110
|
+
.poster .dek { font-size: 15px; }
|
|
111
|
+
.key li, .key li b { font-size: 14px; }
|
|
112
|
+
}
|
|
113
|
+
</style>
|
|
114
|
+
</head>
|
|
115
|
+
<body>
|
|
116
|
+
<div id="chart"></div>
|
|
117
|
+
<script type="module">
|
|
118
|
+
import { Chart, Plot, Scale, Bar, Place, Poster, slat, every, running, html, render, createSignal, createEffect } from "@bezda/rhp/standalone";
|
|
119
|
+
|
|
120
|
+
// Data: average hours per day in 2025, for Americans aged 15 and older, over all seven days of the week, people with
|
|
121
|
+
// and without jobs alike. U.S. Bureau of Labor Statistics, American Time Use Survey, 2025 results (released June 25,
|
|
122
|
+
// 2026), table 1. Each activity includes the travel that goes with it. The rows run clockwise round the dial.
|
|
123
|
+
const GROUPS = ["Sleep, meals & self-care", "Work & school", "Home & family", "Leisure & sports", "Everything else"];
|
|
124
|
+
const DAY = [
|
|
125
|
+
{ group: 0, name: "Sleep", short: "Sleep", hours: 9.03 },
|
|
126
|
+
{ group: 0, name: "Eating and drinking", short: "Meals", hours: 1.21 },
|
|
127
|
+
{ group: 0, name: "Grooming and health", short: "Grooming", hours: 0.77 }, // personal care (9.80) less sleep
|
|
128
|
+
{ group: 1, name: "Paid work", short: "Work", hours: 3.32 }, // working and work-related activities
|
|
129
|
+
{ group: 1, name: "Classes and homework", short: "School", hours: 0.44 },
|
|
130
|
+
{ group: 2, name: "Housework, cooking and yard", short: "Chores", hours: 1.99 },
|
|
131
|
+
{ group: 2, name: "Shopping and errands", short: "Errands", hours: 0.69 },
|
|
132
|
+
{ group: 2, name: "Caring for others", short: "Care", hours: 0.64 }, // household members (0.49) and others (0.15)
|
|
133
|
+
{ group: 3, name: "Watching TV", short: "TV", hours: 2.61 },
|
|
134
|
+
{ group: 3, name: "Reading, games and other leisure", short: "Leisure", hours: 1.63 }, // leisure (5.16) less the three around it
|
|
135
|
+
{ group: 3, name: "Socializing", short: "Social", hours: 0.58 },
|
|
136
|
+
{ group: 3, name: "Sports and exercise", short: "Sport", hours: 0.34 },
|
|
137
|
+
{ group: 4, name: "Worship, volunteering, calls, mail and other", short: "Other", hours: 0.74 }, // 0.28 + 0.19 + 0.27
|
|
138
|
+
];
|
|
139
|
+
const NOTE = "Source: U.S. Bureau of Labor Statistics, American Time Use Survey, 2025 results (June 2026), table 1: average hours per day, people aged 15 and older, all days of the week. Each activity includes the travel it takes. Times are rounded, so they may not add up to exactly 24 hours.";
|
|
140
|
+
|
|
141
|
+
// What depends on the data: each activity's stretch of the dial, the group totals and the headline's numbers
|
|
142
|
+
const { from, to } = running(DAY.map((a) => a.hours)); // where each activity starts and ends, in hours from the top
|
|
143
|
+
const groupHours = GROUPS.map((_, g) => DAY.filter((a) => a.group === g).reduce((sum, a) => sum + a.hours, 0));
|
|
144
|
+
const LEAD = DAY.findIndex((a) => a.name === "Watching TV"); // the activity the headline names: lit until the reader picks another
|
|
145
|
+
const tvShare = DAY[LEAD].hours / groupHours[DAY[LEAD].group]; // 0.51 of leisure
|
|
146
|
+
const percent = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 0 });
|
|
147
|
+
const minutes = (hours) => Math.round(hours * 60);
|
|
148
|
+
const short = (hours) => {
|
|
149
|
+
const m = minutes(hours);
|
|
150
|
+
return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, "0")}`;
|
|
151
|
+
};
|
|
152
|
+
const long = (hours) => {
|
|
153
|
+
const m = minutes(hours), h = Math.floor(m / 60), r = m % 60;
|
|
154
|
+
return [h && `${h} hour${h > 1 ? "s" : ""}`, r && `${r} minute${r > 1 ? "s" : ""}`].filter(Boolean).join(" ");
|
|
155
|
+
};
|
|
156
|
+
const share = Math.abs(tvShare - 0.5) < 0.025 ? "Half" : percent.format(tvShare);
|
|
157
|
+
const HEADLINE = `${share} of our leisure time goes to the TV: ${long(DAY[LEAD].hours)} a day`;
|
|
158
|
+
const say = (i) => `${DAY[i].name}: ${long(DAY[i].hours)} a day, ${percent.format(DAY[i].hours / 24)} of the day`;
|
|
159
|
+
|
|
160
|
+
// The dial's drawing, in a 100 x 100 box around the middle (radius 50): the activities' band runs from radius 30 to 38,
|
|
161
|
+
// with a hairline gap at each end (under 2 minutes of the day, so even the shortest stretch keeps its length).
|
|
162
|
+
// Angles are turns from twelve o'clock, clockwise.
|
|
163
|
+
const TAU = Math.PI * 2;
|
|
164
|
+
const R_IN = 30, R_OUT = 38, R_TEXT = 34, GAP = 0.0012;
|
|
165
|
+
const xy = (t, r) => `${(r * Math.sin(t * TAU)).toFixed(3)},${(-r * Math.cos(t * TAU)).toFixed(3)}`;
|
|
166
|
+
const band = (fromH, toH) => {
|
|
167
|
+
const a = fromH / 24 + GAP, b = toH / 24 - GAP, big = b - a > 0.5 ? 1 : 0;
|
|
168
|
+
return `M${xy(a, R_OUT)}A${R_OUT},${R_OUT} 0 ${big} 1 ${xy(b, R_OUT)}L${xy(b, R_IN)}A${R_IN},${R_IN} 0 ${big} 0 ${xy(a, R_IN)}Z`;
|
|
169
|
+
};
|
|
170
|
+
// The path a label runs along: clockwise on the top half, the other way on the bottom half, so the text stands upright
|
|
171
|
+
const textPath = (fromH, toH) => {
|
|
172
|
+
const a = fromH / 24, b = toH / 24, mid = (a + b) / 2, big = b - a > 0.5 ? 1 : 0;
|
|
173
|
+
return mid > 0.25 && mid < 0.75
|
|
174
|
+
? `M${xy(b, R_TEXT)}A${R_TEXT},${R_TEXT} 0 ${big} 0 ${xy(a, R_TEXT)}`
|
|
175
|
+
: `M${xy(a, R_TEXT)}A${R_TEXT},${R_TEXT} 0 ${big} 1 ${xy(b, R_TEXT)}`;
|
|
176
|
+
};
|
|
177
|
+
// A label goes on the band only where it fits, with room at both ends: its name and time, its name alone, or nothing.
|
|
178
|
+
// Widths are measured in ems once the label font is in; until then no label is drawn.
|
|
179
|
+
const FONT_PHONE = 3.6, FONT_WIDE = 2.9; // in the box's units: 11.6px on a 322px dial, 16px on a 560px one
|
|
180
|
+
const LABEL_FONT = '600 100px "Albert Sans", Arial, sans-serif';
|
|
181
|
+
const [ems, setEms] = createSignal(null);
|
|
182
|
+
const pen = document.createElement("canvas").getContext("2d");
|
|
183
|
+
document.fonts.load(LABEL_FONT).finally(() => {
|
|
184
|
+
pen.font = LABEL_FONT;
|
|
185
|
+
setEms(() => (text) => pen.measureText(text).width / 100);
|
|
186
|
+
});
|
|
187
|
+
const arcLength = (hours) => TAU * R_TEXT * (hours / 24 - 2 * GAP);
|
|
188
|
+
const fits = (text, hours, size) => ems()(text) * size + 3 <= arcLength(hours);
|
|
189
|
+
const labelOf = (a, size) => {
|
|
190
|
+
if (!ems()) return "";
|
|
191
|
+
const full = `${a.short} ${short(a.hours)}`;
|
|
192
|
+
return fits(full, a.hours, size) ? full : fits(a.short, a.hours, size) ? a.short : "";
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const THEME = {
|
|
196
|
+
font: "var(--text)", ink: "var(--ink)", muted: "var(--soft)", grid: "var(--rule)", surface: "var(--paper)",
|
|
197
|
+
series: ["var(--accent)", "var(--quiet)", "var(--bezel)"],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Slat types
|
|
201
|
+
// One hour of the bezel: a numeral every two hours, a dot between them, the bezel's triangle at the top.
|
|
202
|
+
// The Place gives up its point to the middle of the dial and turns its mark round it by --rhp-p (its value, 0 to 1).
|
|
203
|
+
const Hour = slat({
|
|
204
|
+
room: {},
|
|
205
|
+
css: `
|
|
206
|
+
.hour { pointer-events: none; container-type: size; }
|
|
207
|
+
.pt { left: 50%; top: 50%; }
|
|
208
|
+
.pt > * { position: absolute; left: 0; top: 0; translate: -50% -50%;
|
|
209
|
+
transform: rotate(calc(var(--rhp-p) * 1turn)) translateY(-45.4cqmin) rotate(calc(var(--rhp-p) * -1turn)); }
|
|
210
|
+
.num { font: 600 clamp(11px, 3.7cqmin, 18px)/1 "Saira", "Arial Narrow", sans-serif; font-stretch: 112%; color: var(--rhp-ink); font-variant-numeric: tabular-nums; }
|
|
211
|
+
.dot { width: 1.1cqmin; height: 1.1cqmin; border-radius: 50%; background: var(--rhp-muted); }
|
|
212
|
+
.tri { width: 4.4cqmin; height: 3.6cqmin; background: var(--rhp-series-1); clip-path: polygon(0 0, 100% 0, 50% 100%); }
|
|
213
|
+
`,
|
|
214
|
+
}, (t) => html`
|
|
215
|
+
<div class="hour">
|
|
216
|
+
<${Place} at=${() => t.at} class="pt">
|
|
217
|
+
${() => (t.last ? "" : t.at === 0 ? html`<i class="tri"></i>` : t.at % 2 ? html`<i class="dot"></i>` : html`<span class="num">${t.at}</span>`)}
|
|
218
|
+
<//>
|
|
219
|
+
</div>`);
|
|
220
|
+
|
|
221
|
+
// One activity: a Bar from where it starts to where it ends on the 24 hours, which gives up its box to an SVG arc.
|
|
222
|
+
// The arc is drawn in the Bar's color, so the lit one turns gold; the focused one gets an outline.
|
|
223
|
+
const Activity = slat({
|
|
224
|
+
room: { start: 0, end: 0 },
|
|
225
|
+
css: `
|
|
226
|
+
.act { pointer-events: none; container-type: size; outline: none; }
|
|
227
|
+
.arc { inset: 0; width: 100%; height: 100%; translate: none; background: none; }
|
|
228
|
+
.arc svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
|
229
|
+
.band { fill: var(--rhp-color); stroke: transparent; stroke-width: .9; transition: fill .2s; }
|
|
230
|
+
.act:focus-visible .band { stroke: var(--rhp-ink); }
|
|
231
|
+
.lab { font: 600 ${FONT_PHONE}px "Albert Sans", Arial, sans-serif; fill: var(--rhp-series-3); dominant-baseline: central; }
|
|
232
|
+
.lab.wide { display: none; }
|
|
233
|
+
@container (min-width: 460px) {
|
|
234
|
+
.lab { display: none; }
|
|
235
|
+
.lab.wide { display: inline; font-size: ${FONT_WIDE}px; }
|
|
236
|
+
}
|
|
237
|
+
@media (prefers-reduced-motion: reduce) { .band { transition: none; } }
|
|
238
|
+
`,
|
|
239
|
+
}, (d) => html`
|
|
240
|
+
<div class=${() => (d.on ? "act on" : "act")} data-row=${() => d.index} role="img" aria-label=${() => say(d.index)}>
|
|
241
|
+
<${Bar} from=${() => d.from} to=${() => d.to} color=${() => (d.on ? "series-1" : "series-2")} class="arc">
|
|
242
|
+
<svg viewBox="-50 -50 100 100" aria-hidden="true">
|
|
243
|
+
<path class="band" d=${() => band(d.from, d.to)} />
|
|
244
|
+
<path id=${() => `arc-${d.index}`} d=${() => textPath(d.from, d.to)} fill="none" />
|
|
245
|
+
<text class="lab"><textPath href=${() => `#arc-${d.index}`} startOffset="50%" text-anchor="middle">${() => labelOf(d, FONT_PHONE)}</textPath></text>
|
|
246
|
+
<text class="lab wide"><textPath href=${() => `#arc-${d.index}`} startOffset="50%" text-anchor="middle">${() => labelOf(d, FONT_WIDE)}</textPath></text>
|
|
247
|
+
</svg>
|
|
248
|
+
<//>
|
|
249
|
+
</div>`);
|
|
250
|
+
|
|
251
|
+
// Chart component: the activity the reader points at, taps or reaches with the keyboard is lit, and the hand turns to it
|
|
252
|
+
function DayDial() {
|
|
253
|
+
const [on, setOn] = createSignal(null);
|
|
254
|
+
const shown = () => on() ?? LEAD;
|
|
255
|
+
// The hand turns the short way round to the middle of the activity shown
|
|
256
|
+
const middle = (i) => (from[i] + to[i]) / 48;
|
|
257
|
+
const [turn, setTurn] = createSignal(middle(LEAD));
|
|
258
|
+
createEffect(() => {
|
|
259
|
+
const target = middle(shown());
|
|
260
|
+
setTurn((t) => t + ((((target - t) % 1) + 1.5) % 1) - 0.5);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// The activity under the pointer: on the dial, the one whose stretch holds the pointer's angle; in the key, its line
|
|
264
|
+
const rowAt = (e) => {
|
|
265
|
+
const face = e.target.closest?.(".face");
|
|
266
|
+
if (face) {
|
|
267
|
+
const box = face.getBoundingClientRect();
|
|
268
|
+
const x = e.clientX - box.left - box.width / 2, y = e.clientY - box.top - box.height / 2;
|
|
269
|
+
const r = Math.hypot(x, y) / (box.width / 2);
|
|
270
|
+
if (r > 1 || r < 0.12) return null;
|
|
271
|
+
const hour = ((Math.atan2(x, -y) / TAU + 1) % 1) * 24;
|
|
272
|
+
const i = to.findIndex((end) => hour < end);
|
|
273
|
+
return i < 0 ? DAY.length - 1 : i;
|
|
274
|
+
}
|
|
275
|
+
const line = e.target.closest?.("[data-row]");
|
|
276
|
+
return line ? +line.dataset.row : null;
|
|
277
|
+
};
|
|
278
|
+
const focused = (el) => el.closest(".poster").querySelector(".act[data-row]:focus");
|
|
279
|
+
// A mouse or a pen picks as it moves; a tap or a click picks and gives that activity focus; the keyboard picks
|
|
280
|
+
const point = (e) => {
|
|
281
|
+
const row = rowAt(e);
|
|
282
|
+
if (row != null) setOn(row);
|
|
283
|
+
};
|
|
284
|
+
const tap = (e) => {
|
|
285
|
+
const row = rowAt(e);
|
|
286
|
+
if (row == null) return;
|
|
287
|
+
setOn(row);
|
|
288
|
+
e.currentTarget.querySelector(`.act[data-row="${row}"]`)?.focus({ preventScroll: true });
|
|
289
|
+
};
|
|
290
|
+
const focus = (e) => {
|
|
291
|
+
const act = e.target.closest?.(".act[data-row]");
|
|
292
|
+
if (act) setOn(+act.dataset.row);
|
|
293
|
+
};
|
|
294
|
+
// Leaving the dial, the key or the poster: back to the activity that has focus (a keyboard's or a tap's pick), or to the lead
|
|
295
|
+
const leave = (e) => {
|
|
296
|
+
if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
|
|
297
|
+
const act = focused(e.currentTarget);
|
|
298
|
+
setOn(act ? +act.dataset.row : null);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const a = () => DAY[shown()];
|
|
302
|
+
return html`
|
|
303
|
+
<${Poster}
|
|
304
|
+
kicker="The average American day, 2025"
|
|
305
|
+
title=${HEADLINE}
|
|
306
|
+
dek="How Americans aged 15 and older spend 24 hours, averaged over every day of the week and over people with and without jobs. Each stretch of the dial is how long an activity takes in all, not when it happens."
|
|
307
|
+
note=${NOTE}
|
|
308
|
+
onPointerMove=${(e) => e.pointerType !== "touch" && point(e)} onClick=${tap} onFocusIn=${focus}
|
|
309
|
+
onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
|
|
310
|
+
<div class="stage">
|
|
311
|
+
<!-- A press on the face keeps focus where it is, so the pick goes straight from one activity to the next -->
|
|
312
|
+
<div class="face" onMouseDown=${(e) => e.preventDefault()} onPointerLeave=${leave}>
|
|
313
|
+
<${Chart} scale=${[0, 24]} aspect=${1} ticks=${false} theme=${THEME}
|
|
314
|
+
label="The average day in 24 hours: each activity's share of the dial">
|
|
315
|
+
<${Scale} ticks=${every(1)}>${Hour}<//>
|
|
316
|
+
<${Plot} overlap=${true} keyboard=${true} rows=${DAY} from=${from} to=${to} on=${(d) => shown() === d.index}>${Activity}<//>
|
|
317
|
+
<div class="center" aria-hidden="true">
|
|
318
|
+
<div class="hand" style=${() => ({ "--turn": turn() })}><i></i></div>
|
|
319
|
+
<div class="hub">
|
|
320
|
+
<small>${() => GROUPS[a().group]}</small>
|
|
321
|
+
<strong>${() => a().name}</strong>
|
|
322
|
+
<b>${() => {
|
|
323
|
+
const m = minutes(a().hours);
|
|
324
|
+
return m < 60 ? html`${m}<span>min</span>` : html`${Math.floor(m / 60)}<span>h</span>${String(m % 60).padStart(2, "0")}<span>min</span>`;
|
|
325
|
+
}}</b>
|
|
326
|
+
<em>${() => `${percent.format(a().hours / 24)} of the day`}</em>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
<//>
|
|
330
|
+
</div>
|
|
331
|
+
<p class="hint">Point at the dial or tap it. With a keyboard, Tab to it and use the arrow keys.</p>
|
|
332
|
+
</div>
|
|
333
|
+
<div class="key" onPointerLeave=${leave}>
|
|
334
|
+
${GROUPS.map((g, gi) => html`
|
|
335
|
+
<section>
|
|
336
|
+
<h3>${g}<span>${DAY.filter((x) => x.group === gi).length > 1 ? short(groupHours[gi]) : ""}</span></h3>
|
|
337
|
+
<ul>
|
|
338
|
+
${DAY.map((x, i) => (x.group === gi ? html`
|
|
339
|
+
<li data-row=${i} class=${() => (shown() === i ? "on" : "")}><i></i><span>${x.name}</span><b>${short(x.hours)}</b></li>` : null))}
|
|
340
|
+
</ul>
|
|
341
|
+
</section>`)}
|
|
342
|
+
</div>
|
|
343
|
+
<//>`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
render(() => html`<${DayDial} />`, document.getElementById("chart"));
|
|
347
|
+
</script>
|
|
348
|
+
</body>
|
|
349
|
+
</html>
|
|
@@ -0,0 +1,150 @@
|
|
|
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>Staff survey</title>
|
|
7
|
+
<meta name="description" content="Diverging stacked bar chart of survey answers, disagree to the left of zero and agree to the right, ranked by net agreement, with a switch between questions. Use it for Likert scales or any values on either side of zero.">
|
|
8
|
+
<!--
|
|
9
|
+
A recipe gives technique, not a design.
|
|
10
|
+
Take: Bars to both sides of zero stacked by answer, ranked by net agreement, and a switch between questions whose headlines share one grid cell.
|
|
11
|
+
Leave: the design. This poster was made for its own subject (a staff survey as a two-ink risograph form, red for disagree and blue for agree, solid when strong and dotted when not).
|
|
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=Hanken+Grotesk:wght@400..800&family=Syne:wght@700..800&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
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body { margin: 0; padding: 48px 16px; background: #d9d6ce; font-family: "Hanken Grotesk", "Segoe UI", system-ui, sans-serif; }
|
|
21
|
+
/* The poster: a two-ink risograph print, red for disagree and blue for agree, solid when strong, dotted when not */
|
|
22
|
+
.poster { --red: #e0384f; --blue: #2756b0; max-width: 940px; margin: 0 auto; padding: 46px 52px 30px; color: #1f1d1a; border-radius: 2px;
|
|
23
|
+
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='s'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 9 0 0 0 -6.3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23s)' opacity='.16'/%3E%3C/svg%3E"), #f3efe4;
|
|
24
|
+
box-shadow: 0 26px 50px -30px rgb(66 62 52 / .6); }
|
|
25
|
+
.poster figcaption { display: grid; gap: 14px; margin-bottom: 24px; }
|
|
26
|
+
.poster .kicker { font-size: 12px; font-weight: 800; letter-spacing: .2em; text-transform: uppercase; color: var(--blue); }
|
|
27
|
+
.poster .headline { max-width: 16em; font: 800 46px/1.04 Syne, "Arial Black", system-ui, sans-serif; letter-spacing: -.01em; text-wrap: balance; }
|
|
28
|
+
.poster .dek { max-width: 58ch; font-size: 16px; line-height: 1.55; color: #4e4a43; }
|
|
29
|
+
.poster .note { display: block; margin-top: 22px; font-size: 12px; line-height: 1.5; color: #5d5a52; }
|
|
30
|
+
/* The question switch, the question itself, and the answer scale as a survey form prints it */
|
|
31
|
+
.questions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
32
|
+
.questions button { padding: 8px 14px; border: 2px solid #1f1d1a; border-radius: 99px; background: none; color: #1f1d1a; font: 700 14px/1 "Hanken Grotesk", system-ui, sans-serif; cursor: pointer; }
|
|
33
|
+
.questions button[aria-pressed="true"] { background: #1f1d1a; color: #f3efe4; }
|
|
34
|
+
.questions button:focus-visible { outline: 3px solid var(--blue); outline-offset: 2px; }
|
|
35
|
+
.question { margin: 20px 0 16px; font: 750 26px/1.2 Syne, "Arial Black", system-ui, sans-serif; color: var(--blue); }
|
|
36
|
+
/* Every question's headline (and its words) in one grid cell, only the picked one shown: the cell is as tall as the
|
|
37
|
+
longest, so a switch never moves the buttons or the chart */
|
|
38
|
+
.same-place { display: grid; }
|
|
39
|
+
.same-place > span { grid-area: 1 / 1; visibility: hidden; }
|
|
40
|
+
.same-place > .on { visibility: visible; }
|
|
41
|
+
.answers { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 0 0 14px; padding: 0; list-style: none; font-size: 13px; font-weight: 600; color: #4e4a43; }
|
|
42
|
+
.answers li { display: flex; align-items: center; gap: 7px; }
|
|
43
|
+
.answers i { width: 15px; height: 15px; border-radius: 50%; border: 2px solid #1f1d1a; }
|
|
44
|
+
.answers .no2 { background: var(--red); }
|
|
45
|
+
.answers .no1 { background: radial-gradient(circle, var(--red) 1.7px, transparent 2.1px) 0 0 / 5px 5px; }
|
|
46
|
+
.answers .neither { background: #f3efe4; }
|
|
47
|
+
.answers .yes1 { background: radial-gradient(circle, var(--blue) 1.7px, transparent 2.1px) 0 0 / 5px 5px; }
|
|
48
|
+
.answers .yes2 { background: var(--blue); }
|
|
49
|
+
@media (max-width: 600px) {
|
|
50
|
+
body { padding: 16px 16px 32px; }
|
|
51
|
+
.poster { padding: 26px 16px 18px; }
|
|
52
|
+
.poster .headline { font-size: 30px; }
|
|
53
|
+
.poster .dek { font-size: 15px; }
|
|
54
|
+
.questions { gap: 5px; }
|
|
55
|
+
.questions button { padding: 6px 8px; border-width: 1.5px; font-size: 12.5px; }
|
|
56
|
+
.question { font-size: 18px; }
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<div id="chart"></div>
|
|
62
|
+
<script type="module">
|
|
63
|
+
import { Chart, Plot, Bar, Tick, Label, Poster, slat, sortBy, html, render, createSignal } from "@bezda/rhp/standalone";
|
|
64
|
+
|
|
65
|
+
// Data: % of each department giving each answer, from strongly disagree to strongly agree. Illustrative data; replace with your own.
|
|
66
|
+
const DEPARTMENTS = ["Engineering", "Product", "Design", "Sales", "Marketing", "Customer support", "Finance", "Operations"];
|
|
67
|
+
const QUESTIONS = [
|
|
68
|
+
{ short: "Valued", text: "I feel valued for my work.", finding: "Customer support feels the least valued",
|
|
69
|
+
answers: [[5, 11, 16, 44, 24], [4, 10, 14, 46, 26], [6, 12, 18, 42, 22], [9, 15, 15, 38, 23], [7, 13, 19, 41, 20], [18, 26, 17, 28, 11], [6, 12, 21, 43, 18], [12, 20, 20, 35, 13]] },
|
|
70
|
+
{ short: "Recommend", text: "I would recommend working here.", finding: "Product staff recommend working here the most",
|
|
71
|
+
answers: [[4, 8, 14, 46, 28], [3, 7, 12, 48, 30], [5, 10, 17, 44, 24], [8, 14, 16, 40, 22], [6, 12, 20, 42, 20], [14, 22, 20, 32, 12], [5, 11, 22, 44, 18], [10, 18, 22, 36, 14]] },
|
|
72
|
+
{ short: "Workload", text: "My workload is manageable.", finding: "Workload is the sore point in every department",
|
|
73
|
+
answers: [[16, 24, 18, 30, 12], [14, 22, 20, 32, 12], [12, 20, 22, 34, 12], [20, 26, 16, 28, 10], [13, 21, 22, 33, 11], [28, 30, 14, 20, 8], [10, 18, 24, 36, 12], [22, 27, 18, 24, 9]] },
|
|
74
|
+
{ short: "Direction", text: "I understand where the company is heading.", finding: "Support is the least sure where the company is heading",
|
|
75
|
+
answers: [[6, 14, 20, 40, 20], [3, 8, 13, 46, 30], [7, 15, 22, 38, 18], [4, 9, 14, 45, 28], [5, 11, 17, 44, 23], [15, 24, 22, 29, 10], [5, 12, 20, 43, 20], [11, 19, 24, 34, 12]] },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// [strongly disagree, disagree, neither, agree, strongly agree] -> the two sides and the middle
|
|
79
|
+
const disagree = (a) => a[0] + a[1];
|
|
80
|
+
const agree = (a) => a[3] + a[4];
|
|
81
|
+
|
|
82
|
+
// Texts that change with the question, all in the same place (see .same-place), the one for question current() shown
|
|
83
|
+
const samePlace = (texts, current) => html`<span class="same-place">${texts.map((text, i) => html`<span class=${() => (current() === i ? "on" : "")}>${text}</span>`)}</span>`;
|
|
84
|
+
|
|
85
|
+
// Slat type: a department's name over its bar, which runs left of zero for disagree and right of it for agree.
|
|
86
|
+
// A strong part starts 2px after the plain part before it, and on zero when there is none; one too thin to show at
|
|
87
|
+
// this width keeps a 2px sliver, drawn over its neighbor.
|
|
88
|
+
const strong = (side, plain, share) => ["part", side, "strong", plain > 0 && "after", share > 0 && share < 3 && "thin"].filter(Boolean).join(" ");
|
|
89
|
+
const Department = slat({
|
|
90
|
+
thickness: 64,
|
|
91
|
+
inset: "21px",
|
|
92
|
+
room: { start: 18, end: 18 },
|
|
93
|
+
css: `
|
|
94
|
+
.name { position: absolute; left: 0; top: 1px; font-size: 14px; font-weight: 800; }
|
|
95
|
+
.neither { position: absolute; right: 0; top: 2px; font-size: 12px; font-weight: 600; color: var(--rhp-muted); }
|
|
96
|
+
.part { --rhp-radius: 0px; }
|
|
97
|
+
.part.no { background: radial-gradient(circle, #e0384f 1.7px, transparent 2.1px) 0 0 / 5px 5px; }
|
|
98
|
+
.part.yes { background: radial-gradient(circle, #2756b0 1.7px, transparent 2.1px) 0 0 / 5px 5px; }
|
|
99
|
+
.part.no.strong { background: #e0384f; --rhp-end-radius: 3px; }
|
|
100
|
+
.part.yes.strong { background: #2756b0; --rhp-end-radius: 3px; }
|
|
101
|
+
.after { --rhp-gap: 2px; }
|
|
102
|
+
.thin { min-width: 2px; z-index: 1; }
|
|
103
|
+
.zero { background: #1f1d1a; }
|
|
104
|
+
.total { font-size: 13px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
|
105
|
+
.total.no { color: #c02a40; }
|
|
106
|
+
.total.yes { color: #2756b0; }
|
|
107
|
+
`,
|
|
108
|
+
}, (d) => html`
|
|
109
|
+
<div aria-label=${() => `${d.name}: ${disagree(d.answers)}% disagree, ${agree(d.answers)}% agree, ${d.answers[2]}% neither`}>
|
|
110
|
+
<span class="name" aria-hidden="true">${() => d.name}</span>
|
|
111
|
+
<span class="neither" aria-hidden="true">${() => d.answers[2] + "% neither"}</span>
|
|
112
|
+
<${Bar} to=${() => -d.answers[1]} class="part no" />
|
|
113
|
+
<${Bar} from=${() => -d.answers[1]} to=${() => -disagree(d.answers)} class=${() => strong("no", d.answers[1], d.answers[0])} />
|
|
114
|
+
<${Bar} to=${() => d.answers[3]} class="part yes" />
|
|
115
|
+
<${Bar} from=${() => d.answers[3]} to=${() => agree(d.answers)} class=${() => strong("yes", d.answers[3], d.answers[4])} />
|
|
116
|
+
<${Tick} at=${0} thick="34px" class="zero" />
|
|
117
|
+
<${Label} at=${() => -disagree(d.answers)} side="before" class="total no" aria-hidden="true">${() => disagree(d.answers) + "%"}<//>
|
|
118
|
+
<${Label} at=${() => agree(d.answers)} class="total yes" aria-hidden="true">${() => agree(d.answers) + "%"}<//>
|
|
119
|
+
</div>`);
|
|
120
|
+
|
|
121
|
+
// Chart component: the question picked switches the data, the headline and the order of the departments
|
|
122
|
+
function StaffSurvey() {
|
|
123
|
+
const [q, setQ] = createSignal(0);
|
|
124
|
+
const question = () => QUESTIONS[q()];
|
|
125
|
+
return html`
|
|
126
|
+
<${Poster}
|
|
127
|
+
kicker="Staff survey · eight departments"
|
|
128
|
+
title=${samePlace(QUESTIONS.map((x) => x.finding), q)}
|
|
129
|
+
dek="The share of each department that disagrees (left of the line) or agrees (right of it), strongly in solid ink. Departments are ranked by how many more agree than disagree."
|
|
130
|
+
note="Illustrative data. Shares are rounded to whole percents.">
|
|
131
|
+
<div class="questions" role="group" aria-label="Question">
|
|
132
|
+
${QUESTIONS.map((x, i) => html`<button type="button" aria-pressed=${() => q() === i} onClick=${() => setQ(i)}>${x.short}</button>`)}
|
|
133
|
+
</div>
|
|
134
|
+
<p class="question" aria-live="polite">${samePlace(QUESTIONS.map((x) => "“" + x.text + "”"), q)}</p>
|
|
135
|
+
<ul class="answers" aria-hidden="true">
|
|
136
|
+
<li><i class="no2"></i>Strongly disagree</li><li><i class="no1"></i>Disagree</li><li><i class="neither"></i>Neither</li><li><i class="yes1"></i>Agree</li><li><i class="yes2"></i>Strongly agree</li>
|
|
137
|
+
</ul>
|
|
138
|
+
<${Chart} scale=${[-100, 100]} ticks=${[-100, -50, 0, 50, 100]} format=${(v) => Math.abs(v) + "%"}
|
|
139
|
+
label=${() => "Staff survey answers by department: " + question().text}
|
|
140
|
+
theme=${{ font: '"Hanken Grotesk", "Segoe UI", system-ui, sans-serif', ink: "#1f1d1a", muted: "#5d5a52", grid: "#ddd8cb" }}>
|
|
141
|
+
<${Plot} name=${DEPARTMENTS} answers=${() => question().answers} key="name"
|
|
142
|
+
order=${sortBy((d) => agree(d.answers) - disagree(d.answers), "desc")}>${Department}<//>
|
|
143
|
+
<//>
|
|
144
|
+
<//>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
render(() => html`<${StaffSurvey} />`, document.getElementById("chart"));
|
|
148
|
+
</script>
|
|
149
|
+
</body>
|
|
150
|
+
</html>
|