@doan-labs/peek 0.1.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 +38 -0
- package/dist/animate.d.ts +58 -0
- package/dist/draw.d.ts +69 -0
- package/dist/identity.d.ts +55 -0
- package/dist/identity.js +398 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1700 -0
- package/dist/peek.d.ts +13 -0
- package/dist/svg.d.ts +33 -0
- package/dist/svg.js +1053 -0
- package/dist/tables.d.ts +258 -0
- package/package.json +70 -0
- package/src/animate.ts +670 -0
- package/src/draw.ts +849 -0
- package/src/identity.ts +147 -0
- package/src/index.ts +28 -0
- package/src/peek.tsx +98 -0
- package/src/svg.ts +94 -0
- package/src/tables.ts +414 -0
package/src/identity.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A name in, an identity out. Every axis hashes on its own seed, so changing
|
|
3
|
+
* one list never moves another axis:
|
|
4
|
+
*
|
|
5
|
+
* seed(axis) = fnv1a(`peek@${version}:${axis}:${tidy(name)}`)
|
|
6
|
+
*
|
|
7
|
+
* The persona (continuous proportions and habits) is transcribed from
|
|
8
|
+
* faceFor() in the v1.2 page: same ranges, same draw order.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type Axis,
|
|
13
|
+
type Brows,
|
|
14
|
+
type Cheeks,
|
|
15
|
+
COLORS,
|
|
16
|
+
type Color,
|
|
17
|
+
type Eyes,
|
|
18
|
+
FACES,
|
|
19
|
+
type Face,
|
|
20
|
+
LATEST,
|
|
21
|
+
type Mouth,
|
|
22
|
+
PARTS,
|
|
23
|
+
type Trait,
|
|
24
|
+
VERSIONS,
|
|
25
|
+
} from './tables'
|
|
26
|
+
|
|
27
|
+
export const STYLE = 'peek'
|
|
28
|
+
|
|
29
|
+
export const tidy = (s: string) =>
|
|
30
|
+
s.normalize('NFC').trim().replace(/\s+/g, ' ').toLowerCase()
|
|
31
|
+
|
|
32
|
+
/** FNV-1a over the UTF-8 bytes. */
|
|
33
|
+
export function fnv1a(str: string) {
|
|
34
|
+
let h = 0x811c9dc5
|
|
35
|
+
for (const b of new TextEncoder().encode(str)) {
|
|
36
|
+
h ^= b
|
|
37
|
+
h = Math.imul(h, 0x01000193) >>> 0
|
|
38
|
+
}
|
|
39
|
+
return h >>> 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mulberry32(a: number) {
|
|
43
|
+
return () => {
|
|
44
|
+
a = (a + 0x6d2b79f5) | 0
|
|
45
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
46
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
47
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type Persona = {
|
|
52
|
+
/** Eye gap offset, in frame units, per side. */
|
|
53
|
+
spread: number
|
|
54
|
+
/** Blink interval factor (animation only). */
|
|
55
|
+
blink: number
|
|
56
|
+
add: {
|
|
57
|
+
browY: number
|
|
58
|
+
browTilt: number
|
|
59
|
+
browArch: number
|
|
60
|
+
rot: number
|
|
61
|
+
hair: number
|
|
62
|
+
gx: number
|
|
63
|
+
my: number
|
|
64
|
+
}
|
|
65
|
+
mul: { eyeS: number; pupil: number; browW: number; mw: number }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The axes a caller may override. Everything a face is. */
|
|
69
|
+
export type Axes = {
|
|
70
|
+
face: Face
|
|
71
|
+
color: Color
|
|
72
|
+
eyes: Eyes
|
|
73
|
+
brows: Brows
|
|
74
|
+
mouth: Mouth
|
|
75
|
+
cheeks: Cheeks
|
|
76
|
+
trait: Trait
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type Identity = Axes & {
|
|
80
|
+
/** The tidied name. */
|
|
81
|
+
key: string
|
|
82
|
+
/** fnv1a of the tidied name, version-free: a fingerprint for display. */
|
|
83
|
+
hash: number
|
|
84
|
+
version: number
|
|
85
|
+
persona: Persona
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What each axis picks from, in list order. Append-only. */
|
|
89
|
+
export const LISTS: { readonly [A in Axis]: readonly Axes[A][] } = {
|
|
90
|
+
face: Object.keys(FACES) as Face[],
|
|
91
|
+
color: Object.keys(COLORS) as Color[],
|
|
92
|
+
...PARTS,
|
|
93
|
+
}
|
|
94
|
+
/** The axes in readout order. */
|
|
95
|
+
export const AXES = Object.keys(LISTS) as Axis[]
|
|
96
|
+
|
|
97
|
+
export function seed(name: string, axis: string, version = LATEST) {
|
|
98
|
+
return fnv1a(`${STYLE}@${version}:${axis}:${tidy(name)}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function identify(
|
|
102
|
+
name: string,
|
|
103
|
+
{ version = LATEST }: { version?: number } = {},
|
|
104
|
+
): Identity {
|
|
105
|
+
const lengths = VERSIONS[version]
|
|
106
|
+
if (!lengths) throw new Error(`peek@${version} does not exist`)
|
|
107
|
+
const pick = <A extends Axis>(axis: A) =>
|
|
108
|
+
LISTS[axis][seed(name, axis, version) % lengths[axis]] as Identity[A]
|
|
109
|
+
|
|
110
|
+
const rnd = mulberry32(seed(name, 'persona', version))
|
|
111
|
+
const r = (a: number, b: number) =>
|
|
112
|
+
Math.round((a + rnd() * (b - a)) * 100) / 100
|
|
113
|
+
// object literal order is the draw order: never reorder these lines
|
|
114
|
+
const persona: Persona = {
|
|
115
|
+
spread: r(-7, 7),
|
|
116
|
+
blink: r(0.75, 1.4),
|
|
117
|
+
add: {
|
|
118
|
+
browY: r(-4, 4),
|
|
119
|
+
browTilt: r(-5, 5),
|
|
120
|
+
browArch: r(-0.2, 0.3),
|
|
121
|
+
rot: r(-3, 3),
|
|
122
|
+
hair: r(-0.2, 0.35),
|
|
123
|
+
gx: r(-0.12, 0.12),
|
|
124
|
+
my: r(-2, 3),
|
|
125
|
+
},
|
|
126
|
+
mul: {
|
|
127
|
+
eyeS: r(0.92, 1.08),
|
|
128
|
+
pupil: r(0.9, 1.12),
|
|
129
|
+
browW: r(0.85, 1.2),
|
|
130
|
+
mw: r(0.85, 1.18),
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
const key = tidy(name)
|
|
134
|
+
return {
|
|
135
|
+
key,
|
|
136
|
+
hash: fnv1a(key),
|
|
137
|
+
version,
|
|
138
|
+
face: pick('face'),
|
|
139
|
+
color: pick('color'),
|
|
140
|
+
eyes: pick('eyes'),
|
|
141
|
+
brows: pick('brows'),
|
|
142
|
+
mouth: pick('mouth'),
|
|
143
|
+
cheeks: pick('cheeks'),
|
|
144
|
+
trait: pick('trait'),
|
|
145
|
+
persona,
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type { Frame, Gaze } from './draw'
|
|
2
|
+
export type { Axes, Identity, Persona } from './identity'
|
|
3
|
+
export { AXES, identify, LISTS, tidy } from './identity'
|
|
4
|
+
export type { PeekProps } from './peek'
|
|
5
|
+
export { Peek } from './peek'
|
|
6
|
+
export type { PeekOptions } from './svg'
|
|
7
|
+
export { toSvg } from './svg'
|
|
8
|
+
export type {
|
|
9
|
+
Axis,
|
|
10
|
+
Brows,
|
|
11
|
+
Channel,
|
|
12
|
+
Cheeks,
|
|
13
|
+
Color,
|
|
14
|
+
Expression,
|
|
15
|
+
Eyes,
|
|
16
|
+
Face,
|
|
17
|
+
Mouth,
|
|
18
|
+
Trait,
|
|
19
|
+
} from './tables'
|
|
20
|
+
export {
|
|
21
|
+
COLORS,
|
|
22
|
+
EXPRESSIONS,
|
|
23
|
+
FACES,
|
|
24
|
+
GROUPS,
|
|
25
|
+
LATEST,
|
|
26
|
+
PARTS,
|
|
27
|
+
VERSIONS,
|
|
28
|
+
} from './tables'
|
package/src/peek.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* <Peek name="Ada" />: the same tree as toSvg, rendered through React. ids
|
|
3
|
+
* come from useId, so the server and the client agree.
|
|
4
|
+
*
|
|
5
|
+
* A name decides identity unless the caller overrides an axis: pass `face`,
|
|
6
|
+
* `color`, `eyes`, `brows`, `mouth`, `cheeks` or `trait` and that axis wins
|
|
7
|
+
* over the hash while every other axis stays as the name made it.
|
|
8
|
+
*
|
|
9
|
+
* With `animate`, the first paint (server and hydration) is still the static
|
|
10
|
+
* pose of `expression`; after mount the shared loop takes over and writes
|
|
11
|
+
* straight to the DOM. The tree React renders stays in the pose the
|
|
12
|
+
* component mounted with, so a change of expression or gaze never reaches
|
|
13
|
+
* React's diff and React never fights the loop.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type CSSProperties,
|
|
18
|
+
createElement,
|
|
19
|
+
type ReactElement,
|
|
20
|
+
type Ref,
|
|
21
|
+
useEffect,
|
|
22
|
+
useId,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
} from 'react'
|
|
26
|
+
import { Live } from './animate'
|
|
27
|
+
import { draw, type Gaze, type Node, restPose } from './draw'
|
|
28
|
+
import { type PeekOptions, settle } from './svg'
|
|
29
|
+
|
|
30
|
+
export type PeekProps = Omit<PeekOptions, 'gaze' | 'id'> & {
|
|
31
|
+
name: string
|
|
32
|
+
/** x and y in -1..1, or 'pointer' to follow the pointer (animate only). */
|
|
33
|
+
gaze?: Gaze | 'pointer'
|
|
34
|
+
/** Bring it to life after mount. Off under prefers-reduced-motion. */
|
|
35
|
+
animate?: boolean
|
|
36
|
+
className?: string
|
|
37
|
+
style?: CSSProperties
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const camel = (k: string) =>
|
|
41
|
+
k.startsWith('aria-') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
|
42
|
+
|
|
43
|
+
function toReact(
|
|
44
|
+
node: Node,
|
|
45
|
+
root?: { ref: Ref<SVGSVGElement>; className?: string; style?: CSSProperties },
|
|
46
|
+
): ReactElement {
|
|
47
|
+
const props: Record<string, unknown> = { key: node.key, ...root }
|
|
48
|
+
for (const k in node.attrs) props[camel(k)] = node.attrs[k]
|
|
49
|
+
return createElement(node.tag, props, ...node.children.map((c) => toReact(c)))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Peek({
|
|
53
|
+
name,
|
|
54
|
+
gaze,
|
|
55
|
+
animate = false,
|
|
56
|
+
className,
|
|
57
|
+
style,
|
|
58
|
+
...rest
|
|
59
|
+
}: PeekProps) {
|
|
60
|
+
const id = `peek${useId().replace(/[^\w-]/g, '')}`
|
|
61
|
+
const fixed = Array.isArray(gaze) ? (gaze as Gaze) : undefined
|
|
62
|
+
const { who, pose, opts } = settle(
|
|
63
|
+
name,
|
|
64
|
+
{ ...rest, gaze: fixed, id },
|
|
65
|
+
animate,
|
|
66
|
+
)
|
|
67
|
+
const svg = useRef<SVGSVGElement>(null)
|
|
68
|
+
const live = useRef<Live | null>(null)
|
|
69
|
+
const expression = rest.expression ?? 'normal'
|
|
70
|
+
const [first] = useState(() => ({ expression, gaze: fixed }))
|
|
71
|
+
|
|
72
|
+
// live: the tree only changes when what the face is changes
|
|
73
|
+
const sig = JSON.stringify([who, opts])
|
|
74
|
+
const tree = draw(
|
|
75
|
+
who,
|
|
76
|
+
animate ? restPose(who, first.expression, first.gaze) : pose,
|
|
77
|
+
opts,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const gazeKey = String(gaze ?? '')
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!animate || !svg.current) return
|
|
83
|
+
const l = new Live(svg.current, tree, who, opts, expression, gaze)
|
|
84
|
+
live.current = l
|
|
85
|
+
return () => {
|
|
86
|
+
l.destroy()
|
|
87
|
+
live.current = null
|
|
88
|
+
}
|
|
89
|
+
}, [animate, sig])
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
live.current?.setExpression(expression)
|
|
92
|
+
}, [expression])
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
live.current?.setGaze(gaze)
|
|
95
|
+
}, [gazeKey])
|
|
96
|
+
|
|
97
|
+
return toReact(tree, { ref: svg, className, style })
|
|
98
|
+
}
|
package/src/svg.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* toSvg: a name in, a standalone SVG string out. Same input, same bytes, on
|
|
3
|
+
* any machine. Shares its defaults with <Peek> through settle().
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type DrawOptions,
|
|
8
|
+
draw,
|
|
9
|
+
type Frame,
|
|
10
|
+
type Gaze,
|
|
11
|
+
type Node,
|
|
12
|
+
type Pose,
|
|
13
|
+
restPose,
|
|
14
|
+
} from './draw'
|
|
15
|
+
import { type Axes, fnv1a, type Identity, identify } from './identity'
|
|
16
|
+
import type { Expression } from './tables'
|
|
17
|
+
|
|
18
|
+
export type PeekOptions = Partial<Axes> & {
|
|
19
|
+
/** px. Drives the stroke ramp (thicker below 96) and the riso print. */
|
|
20
|
+
size?: number
|
|
21
|
+
expression?: Expression
|
|
22
|
+
/** Where the eyes look, x and y in -1..1. */
|
|
23
|
+
gaze?: Gaze
|
|
24
|
+
frame?: Frame
|
|
25
|
+
/** false clips the frame to a circle. Default true. */
|
|
26
|
+
square?: boolean
|
|
27
|
+
/** Accessible name, default the name; false marks it decorative. */
|
|
28
|
+
title?: string | false
|
|
29
|
+
/** Riso plates and grain, visible from 120px up. Default off. */
|
|
30
|
+
riso?: boolean
|
|
31
|
+
/** Pin a style version. Default the latest. */
|
|
32
|
+
version?: number
|
|
33
|
+
/** Prefix for ids inside the SVG. Default derived from everything drawn,
|
|
34
|
+
* so two different avatars inlined in one page never share defs. */
|
|
35
|
+
id?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A name decides identity unless the caller overrides an axis: an explicit
|
|
40
|
+
* face, color or part wins over the hash, and every other axis stays put.
|
|
41
|
+
*/
|
|
42
|
+
export function settle(
|
|
43
|
+
name: string,
|
|
44
|
+
o: PeekOptions,
|
|
45
|
+
live = false,
|
|
46
|
+
): { who: Identity; pose: Pose; opts: DrawOptions } {
|
|
47
|
+
const base = identify(name, { version: o.version })
|
|
48
|
+
const who: Identity = {
|
|
49
|
+
...base,
|
|
50
|
+
face: o.face ?? base.face,
|
|
51
|
+
color: o.color ?? base.color,
|
|
52
|
+
eyes: o.eyes ?? base.eyes,
|
|
53
|
+
brows: o.brows ?? base.brows,
|
|
54
|
+
mouth: o.mouth ?? base.mouth,
|
|
55
|
+
cheeks: o.cheeks ?? base.cheeks,
|
|
56
|
+
trait: o.trait ?? base.trait,
|
|
57
|
+
}
|
|
58
|
+
const pose = restPose(who, o.expression ?? 'normal', o.gaze)
|
|
59
|
+
const look = {
|
|
60
|
+
size: o.size ?? 64,
|
|
61
|
+
frame: o.frame ?? 'ink',
|
|
62
|
+
square: o.square ?? true,
|
|
63
|
+
riso: o.riso ?? false,
|
|
64
|
+
live,
|
|
65
|
+
}
|
|
66
|
+
const id =
|
|
67
|
+
o.id ?? `peek-${fnv1a(JSON.stringify([who, pose, look])).toString(36)}`
|
|
68
|
+
return { who, pose, opts: { ...look, id, title: o.title ?? name } }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// characters XML 1.0 cannot hold at all: C0 controls and lone surrogates
|
|
72
|
+
const ILLEGAL =
|
|
73
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point
|
|
74
|
+
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
|
|
75
|
+
|
|
76
|
+
const esc = (s: string) =>
|
|
77
|
+
s
|
|
78
|
+
.replace(ILLEGAL, '\uFFFD')
|
|
79
|
+
.replace(
|
|
80
|
+
/[&<>"]/g,
|
|
81
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
export function serialize(node: Node): string {
|
|
85
|
+
let a = ''
|
|
86
|
+
for (const k in node.attrs) a += ` ${k}="${esc(node.attrs[k]!)}"`
|
|
87
|
+
if (!node.children.length) return `<${node.tag}${a}/>`
|
|
88
|
+
return `<${node.tag}${a}>${node.children.map(serialize).join('')}</${node.tag}>`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function toSvg(name: string, props: PeekOptions = {}): string {
|
|
92
|
+
const { who, pose, opts } = settle(name, props)
|
|
93
|
+
return serialize(draw(who, pose, opts))
|
|
94
|
+
}
|