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.
Files changed (38) hide show
  1. package/README.md +43 -2
  2. package/dist/components/bar-picker/index.d.ts +61 -0
  3. package/dist/components/bar-picker/index.js +139 -0
  4. package/dist/components/card/index.d.ts +107 -0
  5. package/dist/components/card/index.js +88 -0
  6. package/dist/components/command-dock/index.d.ts +118 -0
  7. package/dist/components/command-dock/index.js +239 -0
  8. package/dist/components/day-grid/index.d.ts +87 -0
  9. package/dist/components/day-grid/index.js +251 -0
  10. package/dist/components/feedback/index.d.ts +135 -0
  11. package/dist/components/feedback/index.js +283 -0
  12. package/dist/components/flow/flow-paths.d.ts +94 -0
  13. package/dist/components/flow/flow-paths.js +270 -0
  14. package/dist/components/flow/index.d.ts +235 -0
  15. package/dist/components/flow/index.js +597 -0
  16. package/dist/components/frame/index.d.ts +189 -0
  17. package/dist/components/frame/index.js +181 -0
  18. package/dist/components/kpi/index.d.ts +211 -0
  19. package/dist/components/kpi/index.js +243 -0
  20. package/dist/components/lane-board/index.d.ts +125 -0
  21. package/dist/components/lane-board/index.js +691 -0
  22. package/dist/components/planet/index.d.ts +132 -0
  23. package/dist/components/planet/index.js +653 -0
  24. package/dist/components/planet/land.d.ts +8 -0
  25. package/dist/components/planet/land.js +122 -0
  26. package/dist/components/range-meter/index.d.ts +70 -0
  27. package/dist/components/range-meter/index.js +170 -0
  28. package/dist/components/rolling-number/index.d.ts +46 -0
  29. package/dist/components/rolling-number/index.js +81 -0
  30. package/dist/components/sheet/index.d.ts +148 -0
  31. package/dist/components/sheet/index.js +330 -0
  32. package/dist/components/tilt-card/index.d.ts +60 -0
  33. package/dist/components/tilt-card/index.js +114 -0
  34. package/dist/index.d.ts +15 -0
  35. package/dist/index.js +15 -0
  36. package/dist/lib/cn.d.ts +3 -0
  37. package/dist/lib/cn.js +6 -0
  38. 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;