panelui-web 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -2
- package/dist/components/bar-picker/index.d.ts +61 -0
- package/dist/components/bar-picker/index.js +139 -0
- package/dist/components/card/index.d.ts +107 -0
- package/dist/components/card/index.js +88 -0
- package/dist/components/command-dock/index.d.ts +118 -0
- package/dist/components/command-dock/index.js +239 -0
- package/dist/components/day-grid/index.d.ts +87 -0
- package/dist/components/day-grid/index.js +251 -0
- package/dist/components/feedback/index.d.ts +135 -0
- package/dist/components/feedback/index.js +283 -0
- package/dist/components/flow/flow-paths.d.ts +94 -0
- package/dist/components/flow/flow-paths.js +270 -0
- package/dist/components/flow/index.d.ts +235 -0
- package/dist/components/flow/index.js +597 -0
- package/dist/components/frame/index.d.ts +189 -0
- package/dist/components/frame/index.js +181 -0
- package/dist/components/kpi/index.d.ts +211 -0
- package/dist/components/kpi/index.js +243 -0
- package/dist/components/lane-board/index.d.ts +125 -0
- package/dist/components/lane-board/index.js +691 -0
- package/dist/components/planet/index.d.ts +132 -0
- package/dist/components/planet/index.js +653 -0
- package/dist/components/planet/land.d.ts +8 -0
- package/dist/components/planet/land.js +122 -0
- package/dist/components/range-meter/index.d.ts +70 -0
- package/dist/components/range-meter/index.js +170 -0
- package/dist/components/rolling-number/index.d.ts +46 -0
- package/dist/components/rolling-number/index.js +81 -0
- package/dist/components/sheet/index.d.ts +148 -0
- package/dist/components/sheet/index.js +330 -0
- package/dist/components/tilt-card/index.d.ts +60 -0
- package/dist/components/tilt-card/index.js +114 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/lib/cn.d.ts +3 -0
- package/dist/lib/cn.js +6 -0
- package/package.json +72 -4
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Planet — the world as dots on a globe you can turn, with things pinned to it.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <Planet defaultRotation={[78, 18]}>
|
|
8
|
+
* <Planet.Globe>
|
|
9
|
+
* <Planet.Marker lat={24.7} lng={46.7} label="Saudi Arabia" count={2} />
|
|
10
|
+
* <Planet.Marker lat={-33.9} lng={151.2} label="Australia" />
|
|
11
|
+
* </Planet.Globe>
|
|
12
|
+
* <Planet.Controls />
|
|
13
|
+
* </Planet>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* ## Two layers
|
|
17
|
+
*
|
|
18
|
+
* The globe is a canvas and the markers are ordinary elements laid over it.
|
|
19
|
+
* The land is several thousand dots, which is a job for a canvas; a marker is
|
|
20
|
+
* a label, an avatar, maybe a link, which is a job for the DOM. Each frame the
|
|
21
|
+
* canvas is redrawn and every marker is moved to where its coordinate now is,
|
|
22
|
+
* by a transform written straight to the element. Turning the globe never
|
|
23
|
+
* re-renders React.
|
|
24
|
+
*
|
|
25
|
+
* ## Nothing is drawn that is not asked for
|
|
26
|
+
*
|
|
27
|
+
* There is no loop running while the globe is still. A drag, a press on a
|
|
28
|
+
* control, a resize or a change of theme asks for one frame, and a frame asks
|
|
29
|
+
* for another only while something is still moving.
|
|
30
|
+
*
|
|
31
|
+
* ## The land is a question, not a shape
|
|
32
|
+
*
|
|
33
|
+
* `land.ts` says, per square degree, whether it is land. A grid of points is
|
|
34
|
+
* laid over the sphere at an even spacing and the ones on land are kept, so
|
|
35
|
+
* the coastlines come out as the edge of a dot pattern. The spacing is in
|
|
36
|
+
* pixels, so a small globe and a large one are dotted equally finely and a
|
|
37
|
+
* small one is not a smear.
|
|
38
|
+
*
|
|
39
|
+
* ## It takes the text colour
|
|
40
|
+
*
|
|
41
|
+
* The dots and the shading are drawn in `currentColor`, read from the element
|
|
42
|
+
* each frame. On a dark surface the land is light and on a light one it is
|
|
43
|
+
* dark, with no colour passed in and nothing to keep in step with the theme.
|
|
44
|
+
*/
|
|
45
|
+
import { createContext, forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react';
|
|
46
|
+
import { tv } from 'tailwind-variants';
|
|
47
|
+
import { LAND, LAND_COLUMNS, LAND_ROWS } from './land.js';
|
|
48
|
+
const planetVariants = tv({
|
|
49
|
+
slots: {
|
|
50
|
+
root: 'flex w-full flex-col items-center gap-4 text-foreground',
|
|
51
|
+
// `touch-pan-y` leaves a vertical swipe to the page. A globe that traps
|
|
52
|
+
// the scroll it sits in is worse than one that only turns sideways.
|
|
53
|
+
globe: 'relative aspect-square w-full max-w-md cursor-grab touch-pan-y select-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background active:cursor-grabbing',
|
|
54
|
+
// The glow's colour is put in a real colour property, so the browser
|
|
55
|
+
// resolves it and the drawing code can read back a colour it can use.
|
|
56
|
+
canvas: 'absolute inset-0 size-full [outline-color:var(--pu-glow)]',
|
|
57
|
+
markers: 'pointer-events-none absolute inset-0',
|
|
58
|
+
// Opacity 0 until the first frame has placed it, so a marker is never
|
|
59
|
+
// seen stacked in the corner it starts in.
|
|
60
|
+
marker: 'pointer-events-auto absolute left-0 top-0 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center opacity-0 will-change-transform',
|
|
61
|
+
// The same label and pin the app library's Map draws: opaque enough to
|
|
62
|
+
// read over the land, with an edge and a shadow so it sits on the globe
|
|
63
|
+
// and not in it.
|
|
64
|
+
// The stem under the label ties it to its pin, so two labels close
|
|
65
|
+
// together can still be told apart.
|
|
66
|
+
label: 'absolute bottom-full mb-2.5 flex items-center gap-1.5 whitespace-nowrap rounded-md border bg-background/95 px-1.5 py-0.5 text-xs font-medium text-foreground shadow-sm after:absolute after:left-1/2 after:top-full after:h-2 after:w-px after:-translate-x-1/2 after:bg-foreground/40',
|
|
67
|
+
count: 'tabular-nums text-muted-foreground',
|
|
68
|
+
// An opaque surface. A tint would let the land show through whatever the
|
|
69
|
+
// pin holds, and initials over a field of dots cannot be read.
|
|
70
|
+
pin: 'relative flex size-8 items-center justify-center overflow-hidden rounded-full border-2 border-[var(--pu-color)] bg-card text-card-foreground shadow-md ring-2 ring-background',
|
|
71
|
+
dot: 'relative size-3.5 rounded-full border-2 border-background bg-[var(--pu-color)] shadow-md',
|
|
72
|
+
// A marker that can be pressed. The button has no box of its own, so the
|
|
73
|
+
// label above it is still placed from the marker and pressing either the
|
|
74
|
+
// label or the pin counts.
|
|
75
|
+
press: 'group/marker flex cursor-pointer flex-col items-center rounded-full outline-none [&>[data-pin]]:transition-[scale,box-shadow] [&>[data-pin]]:duration-200 hover:[&>[data-pin]]:scale-125 focus-visible:[&>[data-pin]]:ring-2 focus-visible:[&>[data-pin]]:ring-ring active:[&>[data-pin]]:scale-110',
|
|
76
|
+
// A ring that spreads from the marker and fades, for one that wants
|
|
77
|
+
// looking at. Still for anyone who has asked for reduced motion.
|
|
78
|
+
pulse: 'pointer-events-none absolute left-1/2 top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-[var(--pu-color)] opacity-70 motion-reduce:hidden',
|
|
79
|
+
// One surface split by a hairline, not two buttons with a gap between.
|
|
80
|
+
controls: 'inline-flex items-stretch divide-x divide-border overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm',
|
|
81
|
+
control: 'inline-flex size-9 cursor-pointer items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground active:bg-muted',
|
|
82
|
+
caption: 'flex items-center px-3 text-sm font-medium',
|
|
83
|
+
},
|
|
84
|
+
variants: {
|
|
85
|
+
/** The colour of the light at the globe's edge. */
|
|
86
|
+
glow: {
|
|
87
|
+
primary: { globe: '[--pu-glow:var(--primary)]' },
|
|
88
|
+
info: { globe: '[--pu-glow:var(--info)]' },
|
|
89
|
+
success: { globe: '[--pu-glow:var(--success)]' },
|
|
90
|
+
warning: { globe: '[--pu-glow:var(--warning)]' },
|
|
91
|
+
destructive: { globe: '[--pu-glow:var(--destructive)]' },
|
|
92
|
+
},
|
|
93
|
+
// The marker that has been picked: larger, with a ring of its own colour.
|
|
94
|
+
selected: {
|
|
95
|
+
true: {
|
|
96
|
+
dot: 'scale-125 shadow-[0_0_0_4px_color-mix(in_oklab,var(--pu-color)_35%,transparent)]',
|
|
97
|
+
pin: 'scale-110 shadow-[0_0_0_4px_color-mix(in_oklab,var(--pu-color)_35%,transparent)]',
|
|
98
|
+
marker: 'z-10',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
color: {
|
|
102
|
+
primary: { marker: '[--pu-color:var(--primary)]' },
|
|
103
|
+
info: { marker: '[--pu-color:var(--info)]' },
|
|
104
|
+
success: { marker: '[--pu-color:var(--success)]' },
|
|
105
|
+
warning: { marker: '[--pu-color:var(--warning)]' },
|
|
106
|
+
destructive: { marker: '[--pu-color:var(--destructive)]' },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
defaultVariants: { color: 'primary', glow: 'primary' },
|
|
110
|
+
});
|
|
111
|
+
/* -------------------------------------------------------------------------- */
|
|
112
|
+
/* The land */
|
|
113
|
+
/* -------------------------------------------------------------------------- */
|
|
114
|
+
const RADIANS = Math.PI / 180;
|
|
115
|
+
/** The globe's radius as a share of half its box. The rest is the glow. */
|
|
116
|
+
const SPHERE = 0.9;
|
|
117
|
+
let landBits = null;
|
|
118
|
+
function isLand(latitude, longitude) {
|
|
119
|
+
if (!landBits) {
|
|
120
|
+
const decoded = atob(LAND);
|
|
121
|
+
landBits = new Uint8Array(decoded.length);
|
|
122
|
+
for (let i = 0; i < decoded.length; i += 1)
|
|
123
|
+
landBits[i] = decoded.charCodeAt(i);
|
|
124
|
+
}
|
|
125
|
+
const row = Math.min(LAND_ROWS - 1, Math.max(0, Math.floor(((90 - latitude) / 180) * LAND_ROWS)));
|
|
126
|
+
const column = Math.min(LAND_COLUMNS - 1, Math.max(0, Math.floor(((longitude + 180) / 360) * LAND_COLUMNS)));
|
|
127
|
+
const cell = row * LAND_COLUMNS + column;
|
|
128
|
+
return ((landBits[cell >> 3] ?? 0) & (1 << (cell & 7))) !== 0;
|
|
129
|
+
}
|
|
130
|
+
const dotCache = new Map();
|
|
131
|
+
/**
|
|
132
|
+
* A grid with `step` degrees between rows, and between the points along each
|
|
133
|
+
* row at the equator. A row nearer a pole is a shorter circle and gets fewer
|
|
134
|
+
* points, so the spacing on the sphere is the same everywhere and the dots do
|
|
135
|
+
* not crowd together towards the top.
|
|
136
|
+
*/
|
|
137
|
+
function dotsFor(step) {
|
|
138
|
+
const cached = dotCache.get(step);
|
|
139
|
+
if (cached)
|
|
140
|
+
return cached;
|
|
141
|
+
const xs = [];
|
|
142
|
+
const ys = [];
|
|
143
|
+
const zs = [];
|
|
144
|
+
const rows = Math.round(180 / step);
|
|
145
|
+
for (let row = 0; row < rows; row += 1) {
|
|
146
|
+
const latitude = 90 - (row + 0.5) * (180 / rows);
|
|
147
|
+
const cos = Math.cos(latitude * RADIANS);
|
|
148
|
+
const sin = Math.sin(latitude * RADIANS);
|
|
149
|
+
const columns = Math.max(1, Math.round((360 / step) * cos));
|
|
150
|
+
for (let column = 0; column < columns; column += 1) {
|
|
151
|
+
const longitude = -180 + (column + 0.5) * (360 / columns);
|
|
152
|
+
if (!isLand(latitude, longitude))
|
|
153
|
+
continue;
|
|
154
|
+
xs.push(cos * Math.sin(longitude * RADIANS));
|
|
155
|
+
ys.push(sin);
|
|
156
|
+
zs.push(cos * Math.cos(longitude * RADIANS));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const dots = {
|
|
160
|
+
x: Float32Array.from(xs),
|
|
161
|
+
y: Float32Array.from(ys),
|
|
162
|
+
z: Float32Array.from(zs),
|
|
163
|
+
count: xs.length,
|
|
164
|
+
};
|
|
165
|
+
dotCache.set(step, dots);
|
|
166
|
+
return dots;
|
|
167
|
+
}
|
|
168
|
+
const PlanetContext = createContext(null);
|
|
169
|
+
function usePlanetContext(part) {
|
|
170
|
+
const context = useContext(PlanetContext);
|
|
171
|
+
if (!context)
|
|
172
|
+
throw new Error(`${part} must be used inside a <Planet>.`);
|
|
173
|
+
return context;
|
|
174
|
+
}
|
|
175
|
+
/* -------------------------------------------------------------------------- */
|
|
176
|
+
/* The engine */
|
|
177
|
+
/* -------------------------------------------------------------------------- */
|
|
178
|
+
/** How far the globe can be tipped. Past this the far pole comes into view. */
|
|
179
|
+
const MAX_TILT = 60;
|
|
180
|
+
/** Pixels between neighbouring dots, whatever size the globe is. */
|
|
181
|
+
const DOT_SPACING = 4.4;
|
|
182
|
+
/** Where the light comes from: in front, a little above and to the left. */
|
|
183
|
+
const LIGHT = { x: -0.3, y: 0.42, z: 0.86 };
|
|
184
|
+
const ALPHA_STEPS = 8;
|
|
185
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
186
|
+
/** A longitude brought back into -180..180, so it never grows without bound. */
|
|
187
|
+
const wrap = (longitude) => ((((longitude + 180) % 360) + 360) % 360) - 180;
|
|
188
|
+
/** The short way round from one longitude to another. */
|
|
189
|
+
const shortest = (from, to) => wrap(to - from);
|
|
190
|
+
function createEngine({ canvas, rotation, autoRotate, listeners, onSettle }) {
|
|
191
|
+
const context = canvas.getContext('2d');
|
|
192
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
193
|
+
let longitude = wrap(rotation[0]);
|
|
194
|
+
let latitude = clamp(rotation[1], -MAX_TILT, MAX_TILT);
|
|
195
|
+
/** Where an eased turn is heading, or `null` when none is under way. */
|
|
196
|
+
let target = null;
|
|
197
|
+
/** Degrees a second the globe is still coasting at after a drag. */
|
|
198
|
+
let velocity = 0;
|
|
199
|
+
let spin = autoRotate;
|
|
200
|
+
let dragging = false;
|
|
201
|
+
let hovering = false;
|
|
202
|
+
let visible = true;
|
|
203
|
+
let frame = 0;
|
|
204
|
+
let last = 0;
|
|
205
|
+
let size = 0;
|
|
206
|
+
let destroyed = false;
|
|
207
|
+
/** Samples of recent drag movement, to turn a flick into a coast. */
|
|
208
|
+
let samples = [];
|
|
209
|
+
const draw = () => {
|
|
210
|
+
if (!context || size === 0)
|
|
211
|
+
return;
|
|
212
|
+
const ratio = window.devicePixelRatio || 1;
|
|
213
|
+
const centre = size / 2;
|
|
214
|
+
// Smaller than the canvas, which leaves a margin for the light round it.
|
|
215
|
+
const radius = (size / 2) * SPHERE;
|
|
216
|
+
const style = getComputedStyle(canvas);
|
|
217
|
+
const color = style.color;
|
|
218
|
+
const glow = style.outlineColor || color;
|
|
219
|
+
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
220
|
+
context.clearRect(0, 0, size, size);
|
|
221
|
+
/*
|
|
222
|
+
* The light round the globe: a ring that starts at the edge and fades
|
|
223
|
+
* outwards. Drawn as a ring and not a disc, because a gradient paints its
|
|
224
|
+
* first colour all the way in to the middle.
|
|
225
|
+
*/
|
|
226
|
+
const halo = context.createRadialGradient(centre, centre, radius * 0.98, centre, centre, size / 2);
|
|
227
|
+
halo.addColorStop(0, glow);
|
|
228
|
+
halo.addColorStop(1, 'transparent');
|
|
229
|
+
context.fillStyle = halo;
|
|
230
|
+
// Faint on purpose. Any stronger and it washes over the land near the
|
|
231
|
+
// edge, which is where the dots are already at their quietest.
|
|
232
|
+
context.globalAlpha = 0.14;
|
|
233
|
+
context.beginPath();
|
|
234
|
+
context.arc(centre, centre, size / 2, 0, Math.PI * 2);
|
|
235
|
+
context.arc(centre, centre, radius, 0, Math.PI * 2, true);
|
|
236
|
+
context.fill('evenodd');
|
|
237
|
+
context.globalAlpha = 1;
|
|
238
|
+
/* The sphere itself: a faint disc, lit from the upper left. */
|
|
239
|
+
context.save();
|
|
240
|
+
context.beginPath();
|
|
241
|
+
context.arc(centre, centre, radius, 0, Math.PI * 2);
|
|
242
|
+
context.clip();
|
|
243
|
+
context.fillStyle = color;
|
|
244
|
+
context.globalAlpha = 0.035;
|
|
245
|
+
context.fillRect(0, 0, size, size);
|
|
246
|
+
const lit = context.createRadialGradient(centre - radius * 0.42, centre - radius * 0.48, radius * 0.05, centre - radius * 0.2, centre - radius * 0.2, radius * 1.15);
|
|
247
|
+
lit.addColorStop(0, color);
|
|
248
|
+
lit.addColorStop(1, 'transparent');
|
|
249
|
+
context.fillStyle = lit;
|
|
250
|
+
context.globalAlpha = 0.11;
|
|
251
|
+
context.fillRect(0, 0, size, size);
|
|
252
|
+
context.restore();
|
|
253
|
+
/* The land. */
|
|
254
|
+
const sinLongitude = Math.sin(longitude * RADIANS);
|
|
255
|
+
const cosLongitude = Math.cos(longitude * RADIANS);
|
|
256
|
+
const sinLatitude = Math.sin(latitude * RADIANS);
|
|
257
|
+
const cosLatitude = Math.cos(latitude * RADIANS);
|
|
258
|
+
// Finer than a degree buys nothing: that is what the land grid resolves.
|
|
259
|
+
const step = clamp(Math.round((DOT_SPACING / (radius * RADIANS)) * 10) / 10, 1, 6);
|
|
260
|
+
const dots = dotsFor(step);
|
|
261
|
+
const dotRadius = Math.max(0.6, radius * 0.0062);
|
|
262
|
+
// One path per level of brightness. Filling several thousand dots one at a
|
|
263
|
+
// time is several thousand state changes; this is eight.
|
|
264
|
+
const paths = Array.from({ length: ALPHA_STEPS }, () => new Path2D());
|
|
265
|
+
for (let i = 0; i < dots.count; i += 1) {
|
|
266
|
+
const dx = dots.x[i] ?? 0;
|
|
267
|
+
const dy = dots.y[i] ?? 0;
|
|
268
|
+
const dz = dots.z[i] ?? 0;
|
|
269
|
+
// Turn about the vertical axis, then tip towards the viewer.
|
|
270
|
+
const x = dx * cosLongitude - dz * sinLongitude;
|
|
271
|
+
const forward = dx * sinLongitude + dz * cosLongitude;
|
|
272
|
+
const depth = dy * sinLatitude + forward * cosLatitude;
|
|
273
|
+
if (depth <= 0)
|
|
274
|
+
continue;
|
|
275
|
+
const y = dy * cosLatitude - forward * sinLatitude;
|
|
276
|
+
const light = Math.max(0, x * LIGHT.x + y * LIGHT.y + depth * LIGHT.z);
|
|
277
|
+
// Dots fade out towards the edge. They are seen nearly side on there,
|
|
278
|
+
// packed into lines, and at full strength the rim reads as a bright ring
|
|
279
|
+
// around a globe whose middle is the part worth looking at.
|
|
280
|
+
const turned = Math.min(1, depth / 0.55);
|
|
281
|
+
const edge = turned * turned * (3 - 2 * turned);
|
|
282
|
+
const alpha = (0.22 + 0.78 * light) * edge;
|
|
283
|
+
const level = Math.min(ALPHA_STEPS - 1, Math.floor(alpha * ALPHA_STEPS));
|
|
284
|
+
const path = paths[level];
|
|
285
|
+
if (!path)
|
|
286
|
+
continue;
|
|
287
|
+
const px = centre + x * radius;
|
|
288
|
+
const py = centre - y * radius;
|
|
289
|
+
// Dots facing the viewer are drawn a little larger than those turned
|
|
290
|
+
// away, as they would be seen.
|
|
291
|
+
const r = dotRadius * (0.72 + 0.42 * depth);
|
|
292
|
+
path.moveTo(px + r, py);
|
|
293
|
+
path.arc(px, py, r, 0, Math.PI * 2);
|
|
294
|
+
}
|
|
295
|
+
context.fillStyle = color;
|
|
296
|
+
paths.forEach((path, level) => {
|
|
297
|
+
context.globalAlpha = (level + 0.5) / ALPHA_STEPS;
|
|
298
|
+
context.fill(path);
|
|
299
|
+
});
|
|
300
|
+
context.globalAlpha = 1;
|
|
301
|
+
/* The markers, which are told where they are rather than drawn here. */
|
|
302
|
+
const project = (markerLatitude, markerLongitude) => {
|
|
303
|
+
const cos = Math.cos(markerLatitude * RADIANS);
|
|
304
|
+
const sin = Math.sin(markerLatitude * RADIANS);
|
|
305
|
+
const dx = cos * Math.sin(markerLongitude * RADIANS);
|
|
306
|
+
const dz = cos * Math.cos(markerLongitude * RADIANS);
|
|
307
|
+
const x = dx * cosLongitude - dz * sinLongitude;
|
|
308
|
+
const forward = dx * sinLongitude + dz * cosLongitude;
|
|
309
|
+
return {
|
|
310
|
+
x: centre + x * radius,
|
|
311
|
+
y: centre - (sin * cosLatitude - forward * sinLatitude) * radius,
|
|
312
|
+
depth: sin * sinLatitude + forward * cosLatitude,
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
for (const listener of listeners)
|
|
316
|
+
listener(project);
|
|
317
|
+
};
|
|
318
|
+
const resting = () => {
|
|
319
|
+
const turning = spin !== 0 && !hovering && !dragging && visible && !reducedMotion.matches;
|
|
320
|
+
return !dragging && target === null && Math.abs(velocity) < 1 && !turning;
|
|
321
|
+
};
|
|
322
|
+
const tick = (now) => {
|
|
323
|
+
frame = 0;
|
|
324
|
+
if (destroyed)
|
|
325
|
+
return;
|
|
326
|
+
const elapsed = last === 0 ? 0 : Math.min(0.05, (now - last) / 1000);
|
|
327
|
+
last = now;
|
|
328
|
+
if (target) {
|
|
329
|
+
// Closes most of the remaining distance each frame, so a turn starts
|
|
330
|
+
// quickly and settles gently however far it has to go.
|
|
331
|
+
const ease = reducedMotion.matches ? 1 : 1 - Math.exp(-elapsed * 6);
|
|
332
|
+
const remaining = shortest(longitude, target.longitude);
|
|
333
|
+
longitude = wrap(longitude + remaining * ease);
|
|
334
|
+
latitude += (target.latitude - latitude) * ease;
|
|
335
|
+
if (Math.abs(remaining) < 0.05 && Math.abs(target.latitude - latitude) < 0.05) {
|
|
336
|
+
longitude = target.longitude;
|
|
337
|
+
latitude = target.latitude;
|
|
338
|
+
target = null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else if (!dragging && Math.abs(velocity) >= 1) {
|
|
342
|
+
longitude = wrap(longitude + velocity * elapsed);
|
|
343
|
+
velocity *= Math.exp(-elapsed * 3.2);
|
|
344
|
+
}
|
|
345
|
+
else if (!dragging && !hovering && visible && spin !== 0 && !reducedMotion.matches) {
|
|
346
|
+
longitude = wrap(longitude + spin * elapsed);
|
|
347
|
+
}
|
|
348
|
+
draw();
|
|
349
|
+
if (resting()) {
|
|
350
|
+
velocity = 0;
|
|
351
|
+
last = 0;
|
|
352
|
+
onSettle([longitude, latitude]);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
frame = requestAnimationFrame(tick);
|
|
356
|
+
};
|
|
357
|
+
const invalidate = () => {
|
|
358
|
+
if (frame === 0 && !destroyed)
|
|
359
|
+
frame = requestAnimationFrame(tick);
|
|
360
|
+
};
|
|
361
|
+
/* Size the canvas to its box, at the density of the screen it is on. */
|
|
362
|
+
const resize = new ResizeObserver(() => {
|
|
363
|
+
const next = Math.round(canvas.clientWidth);
|
|
364
|
+
if (next === 0)
|
|
365
|
+
return;
|
|
366
|
+
const ratio = window.devicePixelRatio || 1;
|
|
367
|
+
size = next;
|
|
368
|
+
canvas.width = Math.round(next * ratio);
|
|
369
|
+
canvas.height = Math.round(next * ratio);
|
|
370
|
+
// Synchronously, not on the next frame: setting the size cleared the
|
|
371
|
+
// canvas, and waiting a frame to redraw would flash it empty.
|
|
372
|
+
draw();
|
|
373
|
+
invalidate();
|
|
374
|
+
});
|
|
375
|
+
resize.observe(canvas);
|
|
376
|
+
/* A theme change repaints nothing by itself, since nothing is animating. */
|
|
377
|
+
const theme = new MutationObserver(invalidate);
|
|
378
|
+
theme.observe(document.documentElement, {
|
|
379
|
+
attributes: true,
|
|
380
|
+
attributeFilter: ['class', 'style', 'data-theme'],
|
|
381
|
+
});
|
|
382
|
+
/* Turning on its own stops while the globe is off screen. */
|
|
383
|
+
const viewport = new IntersectionObserver(([entry]) => {
|
|
384
|
+
visible = entry?.isIntersecting ?? true;
|
|
385
|
+
if (visible)
|
|
386
|
+
invalidate();
|
|
387
|
+
});
|
|
388
|
+
viewport.observe(canvas);
|
|
389
|
+
const onMotionPreference = () => invalidate();
|
|
390
|
+
reducedMotion.addEventListener('change', onMotionPreference);
|
|
391
|
+
return {
|
|
392
|
+
rotateBy(byLongitude, byLatitude = 0) {
|
|
393
|
+
const from = target ?? { longitude, latitude };
|
|
394
|
+
target = {
|
|
395
|
+
longitude: wrap(from.longitude + byLongitude),
|
|
396
|
+
latitude: clamp(from.latitude + byLatitude, -MAX_TILT, MAX_TILT),
|
|
397
|
+
};
|
|
398
|
+
velocity = 0;
|
|
399
|
+
invalidate();
|
|
400
|
+
},
|
|
401
|
+
rotateTo([toLongitude, toLatitude]) {
|
|
402
|
+
target = { longitude: wrap(toLongitude), latitude: clamp(toLatitude, -MAX_TILT, MAX_TILT) };
|
|
403
|
+
velocity = 0;
|
|
404
|
+
invalidate();
|
|
405
|
+
},
|
|
406
|
+
getRotation: () => [longitude, latitude],
|
|
407
|
+
setAutoRotate(speed) {
|
|
408
|
+
spin = speed;
|
|
409
|
+
invalidate();
|
|
410
|
+
},
|
|
411
|
+
dragStart() {
|
|
412
|
+
dragging = true;
|
|
413
|
+
target = null;
|
|
414
|
+
velocity = 0;
|
|
415
|
+
samples = [];
|
|
416
|
+
invalidate();
|
|
417
|
+
},
|
|
418
|
+
dragMove(dx, dy, vertical) {
|
|
419
|
+
if (!dragging || size === 0)
|
|
420
|
+
return;
|
|
421
|
+
// A point on the surface stays under the pointer: an arc the length of
|
|
422
|
+
// the movement, on a sphere of this radius.
|
|
423
|
+
const degrees = 1 / ((size / 2) * RADIANS);
|
|
424
|
+
longitude = wrap(longitude - dx * degrees);
|
|
425
|
+
if (vertical)
|
|
426
|
+
latitude = clamp(latitude + dy * degrees, -MAX_TILT, MAX_TILT);
|
|
427
|
+
const now = performance.now();
|
|
428
|
+
samples.push({ time: now, dx: -dx * degrees });
|
|
429
|
+
samples = samples.filter((sample) => now - sample.time < 120);
|
|
430
|
+
invalidate();
|
|
431
|
+
},
|
|
432
|
+
dragEnd() {
|
|
433
|
+
if (!dragging)
|
|
434
|
+
return;
|
|
435
|
+
dragging = false;
|
|
436
|
+
const now = performance.now();
|
|
437
|
+
const recent = samples.filter((sample) => now - sample.time < 120);
|
|
438
|
+
const first = recent[0];
|
|
439
|
+
if (first && recent.length > 1 && !reducedMotion.matches) {
|
|
440
|
+
const span = Math.max(16, now - first.time) / 1000;
|
|
441
|
+
velocity = clamp(recent.reduce((sum, sample) => sum + sample.dx, 0) / span, -540, 540);
|
|
442
|
+
}
|
|
443
|
+
invalidate();
|
|
444
|
+
},
|
|
445
|
+
setHover(next) {
|
|
446
|
+
hovering = next;
|
|
447
|
+
invalidate();
|
|
448
|
+
},
|
|
449
|
+
invalidate,
|
|
450
|
+
destroy() {
|
|
451
|
+
destroyed = true;
|
|
452
|
+
if (frame !== 0)
|
|
453
|
+
cancelAnimationFrame(frame);
|
|
454
|
+
resize.disconnect();
|
|
455
|
+
theme.disconnect();
|
|
456
|
+
viewport.disconnect();
|
|
457
|
+
reducedMotion.removeEventListener('change', onMotionPreference);
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
const PlanetRoot = forwardRef(function PlanetRoot({ children, className, defaultRotation = [0, 15], autoRotate = false, interactive = true, onRotationChange, ...props }, ref) {
|
|
462
|
+
const slots = planetVariants();
|
|
463
|
+
const engine = useRef(null);
|
|
464
|
+
const listeners = useRef(new Set()).current;
|
|
465
|
+
// The opening view, kept from the first render. The prop says where the
|
|
466
|
+
// globe starts, not where it is.
|
|
467
|
+
const initial = useRef(defaultRotation).current;
|
|
468
|
+
const speed = autoRotate === true ? 6 : autoRotate === false ? 0 : autoRotate;
|
|
469
|
+
const subscribe = useCallback((listener) => {
|
|
470
|
+
listeners.add(listener);
|
|
471
|
+
// A marker added later is placed straight away, not on the next turn.
|
|
472
|
+
engine.current?.invalidate();
|
|
473
|
+
return () => {
|
|
474
|
+
listeners.delete(listener);
|
|
475
|
+
};
|
|
476
|
+
}, [listeners]);
|
|
477
|
+
const rotateBy = useCallback((longitude, latitude) => {
|
|
478
|
+
engine.current?.rotateBy(longitude, latitude);
|
|
479
|
+
}, []);
|
|
480
|
+
const rotateTo = useCallback((rotation) => {
|
|
481
|
+
engine.current?.rotateTo(rotation);
|
|
482
|
+
}, []);
|
|
483
|
+
useImperativeHandle(ref, () => ({
|
|
484
|
+
rotateBy,
|
|
485
|
+
rotateTo,
|
|
486
|
+
getRotation: () => engine.current?.getRotation() ?? initial,
|
|
487
|
+
}), [rotateBy, rotateTo, initial]);
|
|
488
|
+
const context = useMemo(() => ({
|
|
489
|
+
subscribe,
|
|
490
|
+
listeners,
|
|
491
|
+
rotateBy,
|
|
492
|
+
rotateTo,
|
|
493
|
+
engine,
|
|
494
|
+
defaultRotation: initial,
|
|
495
|
+
autoRotate: speed,
|
|
496
|
+
interactive,
|
|
497
|
+
onRotationChange,
|
|
498
|
+
}), [subscribe, listeners, rotateBy, rotateTo, initial, speed, interactive, onRotationChange]);
|
|
499
|
+
return (_jsx(PlanetContext.Provider, { value: context, children: _jsx("div", { className: slots.root({ className }), ...props, children: children }) }));
|
|
500
|
+
});
|
|
501
|
+
PlanetRoot.displayName = 'Planet';
|
|
502
|
+
/** Degrees one press of an arrow key turns the globe. */
|
|
503
|
+
const KEY_STEP = 15;
|
|
504
|
+
function PlanetGlobe({ children, className, color = 'primary', label = 'Globe', ...props }) {
|
|
505
|
+
const slots = planetVariants({ glow: color });
|
|
506
|
+
const planet = usePlanetContext('Planet.Globe');
|
|
507
|
+
const canvas = useRef(null);
|
|
508
|
+
const pointer = useRef(null);
|
|
509
|
+
// Where the globe has come to rest, for the slider semantics below. Updated
|
|
510
|
+
// when it settles, not while it turns, so turning never re-renders.
|
|
511
|
+
const [settled, setSettled] = useState(planet.defaultRotation);
|
|
512
|
+
// Read through a ref so a new callback from the parent does not rebuild the
|
|
513
|
+
// engine and throw away where the globe has been turned to.
|
|
514
|
+
const onRotationChange = useRef(planet.onRotationChange);
|
|
515
|
+
onRotationChange.current = planet.onRotationChange;
|
|
516
|
+
const { engine, defaultRotation, listeners } = planet;
|
|
517
|
+
// The glow is read off the canvas when it draws, so a new colour needs a
|
|
518
|
+
// new drawing to be seen.
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
engine.current?.invalidate();
|
|
521
|
+
}, [engine, color]);
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
if (!canvas.current)
|
|
524
|
+
return;
|
|
525
|
+
const created = createEngine({
|
|
526
|
+
canvas: canvas.current,
|
|
527
|
+
rotation: defaultRotation,
|
|
528
|
+
autoRotate: 0,
|
|
529
|
+
listeners,
|
|
530
|
+
onSettle: (rotation) => {
|
|
531
|
+
setSettled((current) => Math.round(current[0]) === Math.round(rotation[0]) &&
|
|
532
|
+
Math.round(current[1]) === Math.round(rotation[1])
|
|
533
|
+
? current
|
|
534
|
+
: rotation);
|
|
535
|
+
onRotationChange.current?.(rotation);
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
engine.current = created;
|
|
539
|
+
return () => {
|
|
540
|
+
created.destroy();
|
|
541
|
+
engine.current = null;
|
|
542
|
+
};
|
|
543
|
+
// The engine is built once per canvas. What can change later, the
|
|
544
|
+
// turning speed, is pushed into it by the effect below.
|
|
545
|
+
}, [engine, defaultRotation, listeners]);
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
engine.current?.setAutoRotate(planet.autoRotate);
|
|
548
|
+
}, [engine, planet.autoRotate]);
|
|
549
|
+
const onPointerDown = (event) => {
|
|
550
|
+
if (!planet.interactive || event.button !== 0)
|
|
551
|
+
return;
|
|
552
|
+
// A press that starts on a marker belongs to the marker.
|
|
553
|
+
if (event.target.closest('[data-planet-marker]'))
|
|
554
|
+
return;
|
|
555
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
556
|
+
pointer.current = {
|
|
557
|
+
id: event.pointerId,
|
|
558
|
+
x: event.clientX,
|
|
559
|
+
y: event.clientY,
|
|
560
|
+
// A finger moving vertically is scrolling the page, so only a mouse or a
|
|
561
|
+
// pen tips the globe as well as turning it.
|
|
562
|
+
vertical: event.pointerType !== 'touch',
|
|
563
|
+
};
|
|
564
|
+
engine.current?.dragStart();
|
|
565
|
+
};
|
|
566
|
+
const onPointerMove = (event) => {
|
|
567
|
+
const active = pointer.current;
|
|
568
|
+
if (!active || active.id !== event.pointerId)
|
|
569
|
+
return;
|
|
570
|
+
engine.current?.dragMove(event.clientX - active.x, event.clientY - active.y, active.vertical);
|
|
571
|
+
active.x = event.clientX;
|
|
572
|
+
active.y = event.clientY;
|
|
573
|
+
};
|
|
574
|
+
const endDrag = (event) => {
|
|
575
|
+
if (pointer.current?.id !== event.pointerId)
|
|
576
|
+
return;
|
|
577
|
+
pointer.current = null;
|
|
578
|
+
engine.current?.dragEnd();
|
|
579
|
+
};
|
|
580
|
+
const onKeyDown = (event) => {
|
|
581
|
+
if (!planet.interactive)
|
|
582
|
+
return;
|
|
583
|
+
const turn = {
|
|
584
|
+
ArrowLeft: [-KEY_STEP, 0],
|
|
585
|
+
ArrowRight: [KEY_STEP, 0],
|
|
586
|
+
ArrowUp: [0, KEY_STEP],
|
|
587
|
+
ArrowDown: [0, -KEY_STEP],
|
|
588
|
+
};
|
|
589
|
+
const by = turn[event.key];
|
|
590
|
+
if (!by)
|
|
591
|
+
return;
|
|
592
|
+
event.preventDefault();
|
|
593
|
+
planet.rotateBy(by[0], by[1]);
|
|
594
|
+
};
|
|
595
|
+
const longitude = Math.round(settled[0]);
|
|
596
|
+
const side = longitude === 0 ? '' : longitude > 0 ? ' east' : ' west';
|
|
597
|
+
return (_jsxs("div", {
|
|
598
|
+
// A slider, because that is what it is to a keyboard: one value, the
|
|
599
|
+
// longitude facing you, moved with the arrow keys.
|
|
600
|
+
role: planet.interactive ? 'slider' : 'img', "aria-label": label, "aria-orientation": planet.interactive ? 'horizontal' : undefined, "aria-valuemin": planet.interactive ? -180 : undefined, "aria-valuemax": planet.interactive ? 180 : undefined, "aria-valuenow": planet.interactive ? longitude : undefined, "aria-valuetext": planet.interactive ? `Centred on ${Math.abs(longitude)} degrees${side}` : undefined, tabIndex: planet.interactive ? 0 : undefined, className: slots.globe({ className }), onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: endDrag, onPointerCancel: endDrag, onPointerEnter: () => engine.current?.setHover(true), onPointerLeave: () => engine.current?.setHover(false), onKeyDown: onKeyDown, ...props, children: [_jsx("canvas", { ref: canvas, "aria-hidden": "true", className: slots.canvas() }), _jsx("div", { className: slots.markers(), children: children })] }));
|
|
601
|
+
}
|
|
602
|
+
PlanetGlobe.displayName = 'Planet.Globe';
|
|
603
|
+
/** How near the edge a marker starts to fade, as depth. */
|
|
604
|
+
const FADE_FROM = 0.28;
|
|
605
|
+
const FADE_TO = 0.06;
|
|
606
|
+
function PlanetMarker({ lat, lng, label, count, color = 'primary', pulse = false, onClick, selected = false, name, className, labelClassName, children, ...props }) {
|
|
607
|
+
const slots = planetVariants({ color, selected });
|
|
608
|
+
const planet = usePlanetContext('Planet.Marker');
|
|
609
|
+
const element = useRef(null);
|
|
610
|
+
const { subscribe } = planet;
|
|
611
|
+
useEffect(() => subscribe((project) => {
|
|
612
|
+
const node = element.current;
|
|
613
|
+
if (!node)
|
|
614
|
+
return;
|
|
615
|
+
const { x, y, depth } = project(lat, lng);
|
|
616
|
+
const shown = clamp((depth - FADE_TO) / (FADE_FROM - FADE_TO), 0, 1);
|
|
617
|
+
// `translate` is its own property, so it does not replace the classes'
|
|
618
|
+
// transform that centres the pin on the point.
|
|
619
|
+
node.style.translate = `${x.toFixed(2)}px ${y.toFixed(2)}px`;
|
|
620
|
+
node.style.opacity = shown.toFixed(3);
|
|
621
|
+
node.style.pointerEvents = shown > 0.5 ? '' : 'none';
|
|
622
|
+
// A marker on the far side cannot be seen, so it cannot be tabbed to.
|
|
623
|
+
node.toggleAttribute('inert', shown <= 0.5);
|
|
624
|
+
// Nearer the viewer is on top, as it would be on a real globe.
|
|
625
|
+
node.style.zIndex = String(Math.round(depth * 100));
|
|
626
|
+
}), [subscribe, lat, lng]);
|
|
627
|
+
const body = (_jsxs(_Fragment, { children: [label !== undefined && label !== null ? (_jsxs("span", { className: slots.label({ className: labelClassName }), children: [label, count !== undefined && count !== null ? (_jsx("span", { className: slots.count(), children: count })) : null] })) : null, pulse ? _jsx("span", { "aria-hidden": "true", className: slots.pulse() }) : null, children !== undefined && children !== null ? (_jsx("span", { "data-pin": "", className: slots.pin(), children: children })) : (_jsx("span", { "data-pin": "", className: slots.dot() }))] }));
|
|
628
|
+
return (_jsx("div", { ref: element, "data-planet-marker": "", className: slots.marker({ className }), ...props, children: onClick ? (_jsx("button", { type: "button", "aria-label": name ?? (typeof label === 'string' ? label : undefined), "aria-pressed": selected, className: slots.press(), onClick: onClick,
|
|
629
|
+
// Kept from the globe, which would otherwise take the press as the
|
|
630
|
+
// start of a drag and the click would never arrive.
|
|
631
|
+
onPointerDown: (event) => event.stopPropagation(), onKeyDown: (event) => event.stopPropagation(), children: body })) : (body) }));
|
|
632
|
+
}
|
|
633
|
+
PlanetMarker.displayName = 'Planet.Marker';
|
|
634
|
+
const Arrow = ({ flipped }) => (_jsx("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", className: flipped ? 'size-4 -scale-x-100' : 'size-4', fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M3 8h10M9 4l4 4-4 4" }) }));
|
|
635
|
+
function PlanetControls({ step = 60, className, children, ...props }) {
|
|
636
|
+
const slots = planetVariants();
|
|
637
|
+
const planet = usePlanetContext('Planet.Controls');
|
|
638
|
+
return (_jsxs("div", { role: "group", "aria-label": "Turn the globe", className: slots.controls({ className }), ...props, children: [_jsx("button", { type: "button", "aria-label": "Turn west", className: slots.control(), onClick: () => planet.rotateBy(-step), children: _jsx(Arrow, { flipped: true }) }), children !== undefined && children !== null ? (_jsx("span", { className: slots.caption(), children: children })) : null, _jsx("button", { type: "button", "aria-label": "Turn east", className: slots.control(), onClick: () => planet.rotateBy(step), children: _jsx(Arrow, {}) })] }));
|
|
639
|
+
}
|
|
640
|
+
PlanetControls.displayName = 'Planet.Controls';
|
|
641
|
+
export const Planet = Object.assign(PlanetRoot, {
|
|
642
|
+
Globe: PlanetGlobe,
|
|
643
|
+
Marker: PlanetMarker,
|
|
644
|
+
Controls: PlanetControls,
|
|
645
|
+
});
|
|
646
|
+
/*
|
|
647
|
+
* The parts again, by name. `Planet.Globe` is a property of a component, and a
|
|
648
|
+
* Server Component cannot read a property off something imported from a
|
|
649
|
+
* client module, only pass the import itself along. So a page that is a
|
|
650
|
+
* Server Component writes `<PlanetGlobe>`; anywhere else the dotted form is
|
|
651
|
+
* the same thing.
|
|
652
|
+
*/
|
|
653
|
+
export { PlanetGlobe, PlanetMarker, PlanetControls };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Cells per row and rows in the grid: one cell per degree. */
|
|
2
|
+
export declare const LAND_COLUMNS = 360;
|
|
3
|
+
export declare const LAND_ROWS = 180;
|
|
4
|
+
/**
|
|
5
|
+
* Where the land is. One bit per cell, row 0 northernmost and column 0
|
|
6
|
+
* westernmost, packed eight to a byte from the low bit and base64 encoded.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LAND: string;
|