@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.
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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
+ }