@craftedstudios/avatars 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +126 -2
- package/package.json +30 -4
- package/src/avatar.js +55 -0
- package/src/color.js +234 -0
- package/src/cursor.js +137 -0
- package/src/element.js +37 -0
- package/src/goo.js +70 -0
- package/src/index.js +7 -0
- package/src/names.js +44 -0
- package/src/options.js +88 -0
- package/src/react.js +133 -0
- package/src/renderer.js +403 -0
- package/src/seed.js +67 -0
- package/src/shader.js +298 -0
- package/src/still.js +60 -0
- package/types/index.d.ts +114 -0
- package/types/react.d.ts +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Crafted Studios
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @craftedstudios/avatars
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Live shader avatars from any name, email or user ID, plus presence cursors with the avatar built in. Every avatar on a page shares one WebGL context. There's a PNG export for places a shader can't run, and a React entry.
|
|
4
|
+
|
|
5
|
+
See every effect, and try your own seeds, at [avatars.craftedstudios.co](https://avatars.craftedstudios.co).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @craftedstudios/avatars
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No dependencies. React is optional.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
Give each avatar a seed. The seed decides the effect, the colors and the layout, so the same person always gets the same avatar.
|
|
16
|
+
|
|
17
|
+
React:
|
|
18
|
+
|
|
19
|
+
```jsx
|
|
20
|
+
import { Avatar } from '@craftedstudios/avatars/react';
|
|
21
|
+
|
|
22
|
+
<Avatar seed={user.id} size={40} />
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Web component:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { defineAvatarElement } from '@craftedstudios/avatars';
|
|
29
|
+
defineAvatarElement();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```html
|
|
33
|
+
<crafted-avatar seed="ada@northwind.co" size="40"></crafted-avatar>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Plain JavaScript:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import { avatar } from '@craftedstudios/avatars';
|
|
40
|
+
|
|
41
|
+
const a = avatar(el, { seed: 'ada@northwind.co', size: 40 });
|
|
42
|
+
a.update({ effect: 'thermal' });
|
|
43
|
+
a.destroy();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
Every option except `seed` is optional. The same names work as React props, web component attributes (`ring-color` for `ringColor`) and plain JavaScript options.
|
|
49
|
+
|
|
50
|
+
| Option | Default | What it does |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `seed` | required | Any name, email or user ID. |
|
|
53
|
+
| `size` | `32` | Width and height, in CSS pixels. |
|
|
54
|
+
| `effect` | `auto` | `linear`, `radial`, `thermal`, `topography` or `plasma`. `auto` lets the seed choose. |
|
|
55
|
+
| `colors` | from the seed | Up to four CSS colors for the effect, used in place of the seed's. |
|
|
56
|
+
| `motion` | `auto` | `still` draws one frame and stops. |
|
|
57
|
+
| `interaction` | `goo` | What happens on hover: `goo`, `follow`, `repel`, `distort`, `pulse`, `orbit`, `turbulence` or `none`. |
|
|
58
|
+
| `speed` | `1` | How fast it moves, from 0 to 4. |
|
|
59
|
+
| `shape` | `circle` | `circle`, `squircle` or `square`. |
|
|
60
|
+
| `ring` | `0` | An outline, in pixels. `ringColor` sets its color. |
|
|
61
|
+
| `label` | none | A name for screen readers. Without one, the avatar is hidden from them. |
|
|
62
|
+
|
|
63
|
+
`grain`, `distortion`, `scale`, `stretch` and `thickness` fine-tune the look. The [Lab](https://avatars.craftedstudios.co/lab/) shows what each one does.
|
|
64
|
+
|
|
65
|
+
## Seeds
|
|
66
|
+
|
|
67
|
+
The same seed gives the same avatar everywhere: on the server, in the browser and in a PNG. Nothing is uploaded, fetched or stored.
|
|
68
|
+
|
|
69
|
+
Use something stable. An email works until someone changes it; a user ID never does.
|
|
70
|
+
|
|
71
|
+
## Still images
|
|
72
|
+
|
|
73
|
+
For email, native apps and social images, export a PNG. It takes the same options and comes out up to 2048px square.
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
import { toBlob, toDataURL } from '@craftedstudios/avatars';
|
|
77
|
+
|
|
78
|
+
const png = await toBlob('ada@northwind.co', { size: 1024 });
|
|
79
|
+
const src = await toDataURL('ada@northwind.co', { effect: 'radial' });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Cursors
|
|
83
|
+
|
|
84
|
+
For multiplayer, show where people are with their avatar built in. There are two styles: `arrow` puts the avatar beside the name, and `avatar` makes the avatar itself the pointer. Your app already knows where each cursor is, so pass `x` and `y` and the tip lands there, inside the nearest positioned element.
|
|
85
|
+
|
|
86
|
+
```jsx
|
|
87
|
+
import { Cursor } from '@craftedstudios/avatars/react';
|
|
88
|
+
|
|
89
|
+
<Cursor seed={user.id} name={user.name} variant="avatar" x={x} y={y} />
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The seed decides how the cursor looks. `name` is the text on its tag: pass whatever your app shows for that person. Leave it out and the cursor gets an anonymous name from its seed, like Swift Otter. The same seed always gets the same name, so for visitors who aren't signed in, a session ID gives each one their own. Set `name` to `false` for no tag at all.
|
|
93
|
+
|
|
94
|
+
`nameFor(seed)` returns the same anonymous name, for showing it elsewhere, like a list of who's here.
|
|
95
|
+
|
|
96
|
+
Without React, `cursor()` returns a cursor you move as positions arrive. `move()` is cheap enough to call on every update.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { cursor } from '@craftedstudios/avatars';
|
|
100
|
+
|
|
101
|
+
const c = cursor(canvas, { seed: user.id, name: user.name, variant: 'arrow' });
|
|
102
|
+
c.move(x, y);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`zoom` draws a cursor larger, with its avatars still sharp.
|
|
106
|
+
|
|
107
|
+
## Motion and accessibility
|
|
108
|
+
|
|
109
|
+
When someone has asked their device for reduced motion, every avatar holds still and stops reacting to the pointer. You don't need to do anything.
|
|
110
|
+
|
|
111
|
+
To freeze avatars yourself, set `motion` to `still`, and `interaction` to `none` to stop the hover as well. A still avatar draws once and costs nothing after that.
|
|
112
|
+
|
|
113
|
+
```jsx
|
|
114
|
+
<Avatar seed={user.id} motion="still" interaction="none" />
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Performance
|
|
118
|
+
|
|
119
|
+
Every avatar on a page shares one WebGL context, however many there are. Only avatars on screen draw, and everything pauses while the tab is hidden. Without WebGL, each avatar shows a gradient in its own colors instead.
|
|
120
|
+
|
|
121
|
+
## Server rendering
|
|
122
|
+
|
|
123
|
+
The React component renders its gradient on the server, so the page loads with the avatar already in place and nothing shifts when the shader takes over. It works with Next.js and other frameworks that render on the server.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@craftedstudios/avatars",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Live shader avatars from any name, email or user ID, plus presence cursors. One WebGL context for the whole page, a PNG export and a React entry.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Crafted Studios (https://www.craftedstudios.co)",
|
|
8
|
+
"homepage": "https://avatars.craftedstudios.co",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./types/index.d.ts",
|
|
13
|
+
"default": "./src/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./react": {
|
|
16
|
+
"types": "./types/react.d.ts",
|
|
17
|
+
"default": "./src/react.js"
|
|
18
|
+
},
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": ["src", "types", "LICENSE", "README.md"],
|
|
22
|
+
"keywords": ["avatar", "avatars", "cursor", "multiplayer", "webgl", "shader", "gradient", "react", "web-component"],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"react": { "optional": true }
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test test/*.test.js"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/avatar.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { resolve, hostStyle, hostAttrs } from './options.js';
|
|
2
|
+
import { getRenderer, Item } from './renderer.js';
|
|
3
|
+
|
|
4
|
+
// Turns `host` into an avatar: sets its size, shape and fallback gradient, and
|
|
5
|
+
// when WebGL is available, adds the live canvas inside it. React and the web
|
|
6
|
+
// component render their own host and call this; `avatar()` makes one.
|
|
7
|
+
export function mount(host, options, { styled = false, enter = false } = {}) {
|
|
8
|
+
let o = resolve(options);
|
|
9
|
+
const apply = () => {
|
|
10
|
+
if (!styled) Object.assign(host.style, hostStyle(o));
|
|
11
|
+
for (const a of ['role', 'aria-label', 'aria-hidden']) host.removeAttribute(a);
|
|
12
|
+
for (const [k, v] of Object.entries(hostAttrs(o))) host.setAttribute(k, v);
|
|
13
|
+
};
|
|
14
|
+
apply();
|
|
15
|
+
const r = getRenderer();
|
|
16
|
+
let item = null;
|
|
17
|
+
if (r) {
|
|
18
|
+
item = new Item(host, o, { enter });
|
|
19
|
+
host.append(item.canvas);
|
|
20
|
+
r.add(item);
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
element: host,
|
|
24
|
+
update(next) {
|
|
25
|
+
o = resolve({ ...o, ...next });
|
|
26
|
+
apply();
|
|
27
|
+
if (item) {
|
|
28
|
+
item.set(o);
|
|
29
|
+
// The canvas holds the old frame until the next one lands, so the
|
|
30
|
+
// gradient that apply() just restored stays hidden.
|
|
31
|
+
if (item.shown) item.hideFallback();
|
|
32
|
+
r.wake();
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
destroy() {
|
|
36
|
+
if (item) { r.remove(item); item.canvas.remove(); item = null; }
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Plain JS: appends an avatar to `el`.
|
|
42
|
+
// const a = avatar(el, { seed: 'dexter@craftedstudios.co', size: 40 });
|
|
43
|
+
// a.update({ effect: 'thermal' });
|
|
44
|
+
// a.destroy();
|
|
45
|
+
export function avatar(el, options) {
|
|
46
|
+
const host = document.createElement('span');
|
|
47
|
+
host.className = 'crafted-avatar';
|
|
48
|
+
el.append(host);
|
|
49
|
+
const a = mount(host, options);
|
|
50
|
+
return {
|
|
51
|
+
element: host,
|
|
52
|
+
update: a.update,
|
|
53
|
+
destroy() { a.destroy(); host.remove(); },
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/color.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// A seed's colors come from a color harmony. The seed picks a base hue and
|
|
2
|
+
// one of five classic schemes (analogous, triadic, split complementary,
|
|
3
|
+
// tetradic, complementary), and every color is taken at 90 to 100 percent of
|
|
4
|
+
// the strongest version of its hue a screen can show. Lightness follows the
|
|
5
|
+
// hue: each hue is strongest at its own lightness (yellow light, blue deep),
|
|
6
|
+
// so every color sits near that point instead of one lightness for all, which
|
|
7
|
+
// is what turned yellow to khaki.
|
|
8
|
+
//
|
|
9
|
+
// The first color is the seed's base hue and the one that covers most of the
|
|
10
|
+
// avatar; the rest follow in order around the hue circle, so colors that meet
|
|
11
|
+
// in a gradient sit close. The shaders blend them along the hue circle rather
|
|
12
|
+
// than through grey (see blend() in shader.js).
|
|
13
|
+
|
|
14
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
15
|
+
const wrap = (h) => ((h % 360) + 360) % 360;
|
|
16
|
+
const arc = (a, b) => ((b - a + 540) % 360) - 180;
|
|
17
|
+
|
|
18
|
+
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
19
|
+
const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
20
|
+
|
|
21
|
+
// OKLab to linear sRGB.
|
|
22
|
+
function linear(L, a, b) {
|
|
23
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
24
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
25
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
26
|
+
return [
|
|
27
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
28
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
29
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
const fits = (L, C, rad) => linear(L, C * Math.cos(rad), C * Math.sin(rad)).every((v) => v >= -1e-5 && v <= 1 + 1e-5);
|
|
33
|
+
|
|
34
|
+
// The strongest chroma sRGB can show at lightness L and hue h.
|
|
35
|
+
export function maxChroma(L, h) {
|
|
36
|
+
const rad = (wrap(h) * Math.PI) / 180;
|
|
37
|
+
let lo = 0, hi = 0.4;
|
|
38
|
+
for (let i = 0; i < 16; i++) {
|
|
39
|
+
const mid = (lo + hi) / 2;
|
|
40
|
+
if (fits(L, mid, rad)) lo = mid; else hi = mid;
|
|
41
|
+
}
|
|
42
|
+
return lo;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The lightness where a hue is strongest, per whole degree, worked out once.
|
|
46
|
+
const cusps = new Map();
|
|
47
|
+
function cusp(h) {
|
|
48
|
+
const key = Math.round(wrap(h)) % 360;
|
|
49
|
+
if (!cusps.has(key)) {
|
|
50
|
+
let best = 0.6, most = 0;
|
|
51
|
+
for (let L = 0.3; L <= 0.98; L += 0.01) {
|
|
52
|
+
const c = maxChroma(L, key);
|
|
53
|
+
if (c > most) { most = c; best = L; }
|
|
54
|
+
}
|
|
55
|
+
cusps.set(key, best);
|
|
56
|
+
}
|
|
57
|
+
return cusps.get(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// OKLCH to 0..1 sRGB. Chroma above what sRGB can show comes down to the edge,
|
|
61
|
+
// so a color keeps its hue and lightness and loses only strength.
|
|
62
|
+
export function oklch(L, C, h) {
|
|
63
|
+
const c = Math.min(C, maxChroma(L, h));
|
|
64
|
+
const rad = (wrap(h) * Math.PI) / 180;
|
|
65
|
+
return linear(L, c * Math.cos(rad), c * Math.sin(rad)).map((v) => clamp(toGamma(clamp(v, 0, 1)), 0, 1));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Spreads every input bit across the whole word.
|
|
69
|
+
export function scramble(h) {
|
|
70
|
+
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
|
|
71
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
|
|
72
|
+
return (h ^ (h >>> 16)) >>> 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const bits = (w, at, n) => (w >>> at) & ((1 << n) - 1);
|
|
76
|
+
const apart = (a, b) => Math.abs(arc(a, b));
|
|
77
|
+
const range = (w, at, lo, hi) => lo + (bits(w, at, 4) / 15) * (hi - lo);
|
|
78
|
+
|
|
79
|
+
// Hue offsets for each scheme, base hue first.
|
|
80
|
+
const SCHEMES = [
|
|
81
|
+
[0, 30, 60, -30], // analogous
|
|
82
|
+
[0, 120, 240], // triadic
|
|
83
|
+
[0, 150, 210], // split complementary
|
|
84
|
+
[0, 90, 180, 270], // tetradic
|
|
85
|
+
[0, 180, 20, 200], // complementary
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// A color of hue h near full strength: lightness around where the hue is
|
|
89
|
+
// strongest, moved by `shift`, and chroma 90 to 100 percent of the maximum.
|
|
90
|
+
function vivid(h, w, [lo, hi], shift = 0) {
|
|
91
|
+
const L = clamp(cusp(h) + range(w, 0, lo, hi) + shift, 0.5, 0.95);
|
|
92
|
+
return oklch(L, maxChroma(L, h) * range(w, 4, 0.9, 1), h);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A seed's family, from its hue h and its tone k:
|
|
96
|
+
// colors four, for the gradient effects; the first covers most of the
|
|
97
|
+
// avatar, and three-color schemes repeat the base hue lighter
|
|
98
|
+
// light, mid, deep three, for the effects with a ground (lines or
|
|
99
|
+
// highlight, fill or metal, ground)
|
|
100
|
+
export function family(h, k = 0) {
|
|
101
|
+
const word = (i) => scramble(k ^ Math.imul(i + 1, 0x9e3779b9));
|
|
102
|
+
const w0 = word(0);
|
|
103
|
+
const offsets = SCHEMES[w0 % SCHEMES.length];
|
|
104
|
+
const rest = offsets.slice(1).map((d) => wrap(h + d)).sort((a, b) => wrap(a - h) - wrap(b - h));
|
|
105
|
+
if (bits(w0, 8, 1)) rest.reverse();
|
|
106
|
+
const hues = [h, ...rest];
|
|
107
|
+
const colors = hues.map((hue, i) => vivid(hue, word(i + 1), [-0.1, 0.04]));
|
|
108
|
+
if (colors.length < 4) colors.push(vivid(h, word(4), [0.08, 0.16]));
|
|
109
|
+
// The ground is deep, so it can't be orange to green-yellow: dark, those
|
|
110
|
+
// are brown, khaki and olive.
|
|
111
|
+
const grounds = hues.filter((x) => apart(x, 85) > 50);
|
|
112
|
+
const w = word(9);
|
|
113
|
+
const deepHue = grounds.length ? grounds[w % grounds.length] : wrap(h + 180);
|
|
114
|
+
const others = hues.filter((x) => x !== deepHue);
|
|
115
|
+
const L = range(w, 8, 0.3, 0.42);
|
|
116
|
+
return {
|
|
117
|
+
colors,
|
|
118
|
+
light: (() => { const Ll = range(w, 12, 0.88, 0.95); return oklch(Ll, maxChroma(Ll, others[0]) * 0.95, others[0]); })(),
|
|
119
|
+
mid: vivid(others[1 % others.length], word(10), [-0.08, 0.02]),
|
|
120
|
+
deep: oklch(L, maxChroma(L, deepHue) * range(w, 16, 0.85, 1), deepHue),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hsl(h, s, l) {
|
|
125
|
+
s /= 100; l /= 100;
|
|
126
|
+
const k = (n) => (n + h / 30) % 12, a = s * Math.min(l, 1 - l);
|
|
127
|
+
return [0, 8, 4].map((n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Reads a CSS color without a DOM, so the server render can use it: hex,
|
|
131
|
+
// rgb() and hsl(). Returns 0..1 RGB, or null for anything else.
|
|
132
|
+
export function parseColor(str) {
|
|
133
|
+
const c = String(str).trim().toLowerCase();
|
|
134
|
+
let m = /^#([0-9a-f]{3,8})$/.exec(c);
|
|
135
|
+
if (m) {
|
|
136
|
+
let h = m[1];
|
|
137
|
+
if (h.length === 3 || h.length === 4) h = h.replace(/./g, '$&$&');
|
|
138
|
+
if (h.length !== 6 && h.length !== 8) return null;
|
|
139
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
|
140
|
+
}
|
|
141
|
+
m = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(c);
|
|
142
|
+
if (!m) return null;
|
|
143
|
+
const tokens = m[2].split(/[\s,/]+/).filter(Boolean).slice(0, 3);
|
|
144
|
+
const n = tokens.map(parseFloat);
|
|
145
|
+
if (n.length < 3 || n.some((v) => !Number.isFinite(v))) return null;
|
|
146
|
+
if (m[1].startsWith('rgb')) return n.map((v, i) => clamp(tokens[i].endsWith('%') ? v / 100 : v / 255, 0, 1));
|
|
147
|
+
return hsl(wrap(n[0]), clamp(n[1], 0, 100), clamp(n[2], 0, 100));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Each effect has its own colors, in Scenery's roles and with Scenery's
|
|
151
|
+
// defaults. `colors` fills them in this order; any left out keep the default.
|
|
152
|
+
export const EFFECT_COLORS = {
|
|
153
|
+
linear: { roles: ['Start', 'Second', 'Third', 'End'], defaults: ['#FF6B35', '#FF4081', '#7C4DFF', '#1A0A2E'] },
|
|
154
|
+
thermal: { roles: ['Cold', 'Cool', 'Warm', 'Hot'], defaults: ['#2B1BD9', '#21E3FF', '#FFD23F', '#FF2E63'] },
|
|
155
|
+
topography: { roles: ['Lines', 'Fill', 'Ground'], defaults: ['#FFFFFF', '#1A1A2E', '#0A0A12'] },
|
|
156
|
+
radial: { roles: ['Center', 'Second', 'Third', 'Edge'], defaults: ['#FFD700', '#FF6347', '#8B008B', '#0D0D0D'] },
|
|
157
|
+
plasma: { roles: ['First', 'Second', 'Third', 'Fourth'], defaults: ['#FF006E', '#FFBE0B', '#3A86FF', '#8338EC'] },
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const mixRgb = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
|
|
161
|
+
|
|
162
|
+
// How a seed's family fills each effect's roles.
|
|
163
|
+
const RECIPES = {
|
|
164
|
+
linear: (f) => f.colors,
|
|
165
|
+
plasma: (f) => f.colors,
|
|
166
|
+
radial: (f) => f.colors,
|
|
167
|
+
thermal: (f) => f.colors,
|
|
168
|
+
topography: (f) => [f.light, f.mid, f.deep],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// An effect's colors, in its roles, as 0..1 RGB: from `colors` when given,
|
|
172
|
+
// otherwise from the seed's hue and tone. Never both.
|
|
173
|
+
export function effectColors(effect, hue, colors, tone = 0) {
|
|
174
|
+
const spec = EFFECT_COLORS[effect];
|
|
175
|
+
const picked = (Array.isArray(colors) ? colors : []).map(parseColor);
|
|
176
|
+
if (!picked.some(Boolean)) return RECIPES[effect](family(hue, tone));
|
|
177
|
+
return spec.defaults.map((d, i) => picked[i] || parseColor(d));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The four colors the shader takes. Effects with fewer roles leave the rest
|
|
181
|
+
// unused.
|
|
182
|
+
export const shaderColors = (c) => [0, 1, 2, 3].map((i) => c[i] || [0, 0, 0]);
|
|
183
|
+
|
|
184
|
+
export const hex = (c) => '#' + c.map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
185
|
+
|
|
186
|
+
const rgb = ([r, g, b], alpha = 1) =>
|
|
187
|
+
`rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${alpha})`;
|
|
188
|
+
|
|
189
|
+
// What an avatar looks like without WebGL: a few soft spots of its colors over
|
|
190
|
+
// a base, close to the average look of its effect so the swap to the live
|
|
191
|
+
// version is a shift in detail rather than a change of color. One description
|
|
192
|
+
// feeds both the CSS (server render, no WebGL) and Canvas 2D (still images
|
|
193
|
+
// without WebGL), so the two always agree.
|
|
194
|
+
// spots: [x, y, color, reach] x, y and reach as fractions of the size
|
|
195
|
+
export function paint(effect, c) {
|
|
196
|
+
switch (effect) {
|
|
197
|
+
case 'thermal':
|
|
198
|
+
return { base: c[0], spots: [[0.5, 0.62, c[1], 0.45], [0.55, 0.7, c[2], 0.35], [0.45, 0.35, c[3], 0.35]] };
|
|
199
|
+
case 'topography': {
|
|
200
|
+
const [lines, fill, ground] = c;
|
|
201
|
+
return { base: ground, spots: [[0.5, 0.45, mixRgb(ground, fill, 0.6), 0.75], [0.3, 0.75, mixRgb(fill, lines, 0.25), 0.45]] };
|
|
202
|
+
}
|
|
203
|
+
case 'radial':
|
|
204
|
+
return { base: c[3], spots: [[0.5, 0.5, c[0], 0.25], [0.5, 0.5, c[1], 0.45], [0.5, 0.5, c[2], 0.62]] };
|
|
205
|
+
case 'linear':
|
|
206
|
+
return { base: c[1], spots: [[0.1, 0.5, c[0], 0.6], [0.6, 0.5, c[2], 0.5], [0.95, 0.5, c[3], 0.55]] };
|
|
207
|
+
default: // plasma
|
|
208
|
+
return { base: c[1], spots: [[0.25, 0.3, c[0], 0.6], [0.8, 0.25, c[2], 0.6], [0.6, 0.85, c[3], 0.65]] };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function css(effect, c) {
|
|
213
|
+
const { base, spots } = paint(effect, c);
|
|
214
|
+
return [
|
|
215
|
+
...spots.map(([x, y, c, r]) => `radial-gradient(circle at ${x * 100}% ${y * 100}%, ${rgb(c)}, ${rgb(c, 0)} ${r * 100}%)`),
|
|
216
|
+
rgb(base),
|
|
217
|
+
].join(', ');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The same picture on a 2D canvas of side `size`. CSS measures a circle
|
|
221
|
+
// gradient's percentage against the box's diagonal over root two, which for a
|
|
222
|
+
// square box is its side, so `reach * size` matches.
|
|
223
|
+
export function paint2d(ctx, effect, c, size) {
|
|
224
|
+
const { base, spots } = paint(effect, c);
|
|
225
|
+
ctx.fillStyle = rgb(base);
|
|
226
|
+
ctx.fillRect(0, 0, size, size);
|
|
227
|
+
for (const [x, y, c, r] of [...spots].reverse()) {
|
|
228
|
+
const g = ctx.createRadialGradient(x * size, y * size, 0, x * size, y * size, r * size);
|
|
229
|
+
g.addColorStop(0, rgb(c));
|
|
230
|
+
g.addColorStop(1, rgb(c, 0));
|
|
231
|
+
ctx.fillStyle = g;
|
|
232
|
+
ctx.fillRect(0, 0, size, size);
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/cursor.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { mount } from './avatar.js';
|
|
2
|
+
import { traits } from './seed.js';
|
|
3
|
+
import { tagName } from './names.js';
|
|
4
|
+
|
|
5
|
+
// Presence cursors with the avatar built in, in two styles:
|
|
6
|
+
// arrow - a thick arrow in the seed's first two colors, and the live avatar
|
|
7
|
+
// beside the name on a dark tag.
|
|
8
|
+
// avatar - the live avatar itself is the pointer, a teardrop whose sharp
|
|
9
|
+
// corner is the tip, with the name beside it.
|
|
10
|
+
// The tag shows `name`; without one it shows the seed's anonymous name (see
|
|
11
|
+
// names.js), and `name: false` leaves the tag off.
|
|
12
|
+
// Where the cursor is comes from the app (its realtime layer already knows);
|
|
13
|
+
// the tip lands exactly on x, y.
|
|
14
|
+
// const c = cursor(canvasEl, { seed: user.id, name: 'Ada', variant: 'avatar' });
|
|
15
|
+
// c.move(x, y);
|
|
16
|
+
|
|
17
|
+
export const ARROW = 'M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z';
|
|
18
|
+
const ARROW_PX = 26;
|
|
19
|
+
// Where the tip sits inside each cursor's box.
|
|
20
|
+
export const TIP = { arrow: (4 * ARROW_PX) / 24, avatar: 0 };
|
|
21
|
+
|
|
22
|
+
// Cursor avatars never react to the pointer; they are the pointer.
|
|
23
|
+
const QUIET = { interaction: 'none' };
|
|
24
|
+
|
|
25
|
+
export function cursorColors(o) {
|
|
26
|
+
if (Array.isArray(o.colors) && o.colors.length >= 2) return o.colors;
|
|
27
|
+
return traits(o.seed, { effect: o.effect }).colors;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function placeStyle(variant, x, y, zoom = 1) {
|
|
31
|
+
const t = (TIP[variant] ?? 0) * zoom;
|
|
32
|
+
return `translate(${x - t}px, ${y - t}px)`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Every measure takes a zoom, so a cursor can be drawn larger, for a preview
|
|
36
|
+
// or a big screen, with its avatars rendered sharp at that size.
|
|
37
|
+
export const sizes = (s = 1) => ({ arrow: ARROW_PX * s, drop: 28 * s, dot: Math.round(16 * s) });
|
|
38
|
+
|
|
39
|
+
export const styles = {
|
|
40
|
+
root: { position: 'absolute', left: 0, top: 0, display: 'block', pointerEvents: 'none', willChange: 'transform' },
|
|
41
|
+
arrow: (s = 1) => ({
|
|
42
|
+
display: 'block', width: ARROW_PX * s, height: ARROW_PX * s, overflow: 'visible',
|
|
43
|
+
filter: `drop-shadow(0 ${s}px ${3 * s}px rgb(0 0 0 / 0.45))`,
|
|
44
|
+
}),
|
|
45
|
+
drop: (s = 1) => ({
|
|
46
|
+
display: 'block', width: 28 * s, height: 28 * s, overflow: 'hidden', borderRadius: `${s}px 50% 50% 50%`,
|
|
47
|
+
boxShadow: `0 0 0 ${2 * s}px #fff, 0 ${2 * s}px ${8 * s}px rgb(0 0 0 / 0.45)`,
|
|
48
|
+
}),
|
|
49
|
+
tag: (variant, s = 1) => ({
|
|
50
|
+
position: 'absolute', left: (variant === 'avatar' ? 32 : 20) * s, top: (variant === 'avatar' ? 26 : 24) * s,
|
|
51
|
+
display: 'flex', alignItems: 'center', gap: 6 * s, width: 'max-content', height: 24 * s,
|
|
52
|
+
padding: variant === 'avatar' ? `0 ${8 * s}px` : `0 ${8 * s}px 0 ${4 * s}px`,
|
|
53
|
+
borderRadius: `${4 * s}px ${10 * s}px ${10 * s}px ${10 * s}px`,
|
|
54
|
+
background: '#1f1f1f', boxShadow: `inset 0 0 0 ${s}px rgb(255 255 255 / 0.08)`,
|
|
55
|
+
color: 'rgb(255 255 255 / 0.92)', fontSize: 12 * s, fontWeight: 500, lineHeight: `${16 * s}px`, whiteSpace: 'nowrap',
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let ids = 0;
|
|
60
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
61
|
+
|
|
62
|
+
function arrowSvg(colors, zoom) {
|
|
63
|
+
const id = `crafted-cursor-${++ids}`;
|
|
64
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
65
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
66
|
+
svg.setAttribute('width', ARROW_PX * zoom);
|
|
67
|
+
svg.setAttribute('height', ARROW_PX * zoom);
|
|
68
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
69
|
+
Object.assign(svg.style, px(styles.arrow(zoom)));
|
|
70
|
+
svg.innerHTML = `<defs><linearGradient id="${id}" x1="0.1" y1="0.1" x2="0.9" y2="0.9"><stop offset="0" stop-color="${colors[0]}"/><stop offset="1" stop-color="${colors[1]}"/></linearGradient></defs><path d="${ARROW}" fill="url(#${id})" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/>`;
|
|
71
|
+
return svg;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const px = (style) => Object.fromEntries(Object.entries(style).map(([k, v]) => [k, typeof v === 'number' && k !== 'fontWeight' ? `${v}px` : v]));
|
|
75
|
+
|
|
76
|
+
// Plain JS: appends a cursor to `el`, which should be positioned (relative or
|
|
77
|
+
// absolute), since the cursor is placed inside it.
|
|
78
|
+
export function cursor(el, options = {}) {
|
|
79
|
+
let o = { variant: 'arrow', ...options };
|
|
80
|
+
let pos = [o.x ?? 0, o.y ?? 0];
|
|
81
|
+
const root = document.createElement('span');
|
|
82
|
+
root.className = 'crafted-cursor';
|
|
83
|
+
root.setAttribute('aria-hidden', 'true');
|
|
84
|
+
Object.assign(root.style, px(styles.root));
|
|
85
|
+
el.append(root);
|
|
86
|
+
let faces = [];
|
|
87
|
+
|
|
88
|
+
const build = () => {
|
|
89
|
+
faces.forEach((f) => f.destroy());
|
|
90
|
+
faces = [];
|
|
91
|
+
root.replaceChildren();
|
|
92
|
+
const { variant, name, x, y, zoom = 1, ...avatarOptions } = o;
|
|
93
|
+
const look = { ...avatarOptions, ...QUIET };
|
|
94
|
+
const size = sizes(zoom);
|
|
95
|
+
if (variant === 'avatar') {
|
|
96
|
+
const drop = document.createElement('span');
|
|
97
|
+
Object.assign(drop.style, px(styles.drop(zoom)));
|
|
98
|
+
const host = document.createElement('span');
|
|
99
|
+
drop.append(host);
|
|
100
|
+
root.append(drop);
|
|
101
|
+
faces.push(mount(host, { ...look, size: size.drop, shape: 'square' }));
|
|
102
|
+
} else {
|
|
103
|
+
root.append(arrowSvg(cursorColors(o), zoom));
|
|
104
|
+
}
|
|
105
|
+
const label = tagName(name, o.seed);
|
|
106
|
+
if (label) {
|
|
107
|
+
const tag = document.createElement('span');
|
|
108
|
+
Object.assign(tag.style, px(styles.tag(variant, zoom)));
|
|
109
|
+
if (variant !== 'avatar') {
|
|
110
|
+
const host = document.createElement('span');
|
|
111
|
+
tag.append(host);
|
|
112
|
+
faces.push(mount(host, { ...look, size: size.dot }));
|
|
113
|
+
}
|
|
114
|
+
tag.append(label);
|
|
115
|
+
root.append(tag);
|
|
116
|
+
}
|
|
117
|
+
root.style.transform = placeStyle(o.variant, ...pos, o.zoom);
|
|
118
|
+
};
|
|
119
|
+
build();
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
element: root,
|
|
123
|
+
move(x, y) {
|
|
124
|
+
pos = [x, y];
|
|
125
|
+
root.style.transform = placeStyle(o.variant, x, y, o.zoom);
|
|
126
|
+
},
|
|
127
|
+
update(next) {
|
|
128
|
+
o = { ...o, ...next };
|
|
129
|
+
if (next.x !== undefined || next.y !== undefined) pos = [o.x ?? pos[0], o.y ?? pos[1]];
|
|
130
|
+
build();
|
|
131
|
+
},
|
|
132
|
+
destroy() {
|
|
133
|
+
faces.forEach((f) => f.destroy());
|
|
134
|
+
root.remove();
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/element.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mount } from './avatar.js';
|
|
2
|
+
import { DEFAULTS } from './options.js';
|
|
3
|
+
|
|
4
|
+
const ATTRS = ['seed', 'size', 'effect', 'colors', 'motion', 'speed', 'distortion', 'scale', 'grain', 'shape', 'interaction', 'stretch', 'thickness', 'ring', 'ring-color', 'label'];
|
|
5
|
+
const prop = (a) => a.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
6
|
+
|
|
7
|
+
// Registers <crafted-avatar>, for any framework or none.
|
|
8
|
+
// <crafted-avatar seed="dexter@craftedstudios.co" size="40"></crafted-avatar>
|
|
9
|
+
export function defineAvatarElement(name = 'crafted-avatar') {
|
|
10
|
+
if (typeof customElements === 'undefined' || customElements.get(name)) return;
|
|
11
|
+
customElements.define(name, class extends HTMLElement {
|
|
12
|
+
static observedAttributes = ATTRS;
|
|
13
|
+
|
|
14
|
+
options() {
|
|
15
|
+
// Start from the defaults so a removed attribute goes back to its default.
|
|
16
|
+
const o = { ...DEFAULTS };
|
|
17
|
+
for (const a of ATTRS) if (this.hasAttribute(a)) o[prop(a)] = this.getAttribute(a);
|
|
18
|
+
return o;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
connectedCallback() {
|
|
22
|
+
if (!this.avatar) this.avatar = mount(this, this.options());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnectedCallback() {
|
|
26
|
+
// Moving the element in the DOM disconnects and reconnects it in the
|
|
27
|
+
// same task; only tear down if it is really gone.
|
|
28
|
+
queueMicrotask(() => {
|
|
29
|
+
if (!this.isConnected && this.avatar) { this.avatar.destroy(); this.avatar = null; }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
attributeChangedCallback() {
|
|
34
|
+
this.avatar?.update(this.options());
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|