@doan-labs/peek 1.1.0 → 2.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/README.md +45 -1
- package/dist/bloc.d.ts +2 -0
- package/dist/core.d.ts +9 -0
- package/dist/draw.d.ts +24 -2
- package/dist/index.d.ts +1 -9
- package/dist/index.js +589 -207
- package/dist/peek.svelte +75 -0
- package/dist/peek.svelte.d.ts +5 -0
- package/dist/svelte-peek.d.ts +14 -0
- package/dist/svelte-peek.js +2205 -0
- package/dist/svelte.d.ts +3 -0
- package/dist/svelte.js +1650 -0
- package/dist/svg.d.ts +3 -1
- package/dist/svg.js +546 -168
- package/dist/tables.d.ts +53 -0
- package/dist/vue.d.ts +17 -0
- package/dist/vue.js +2302 -0
- package/package.json +28 -5
- package/src/bloc.ts +326 -0
- package/src/core.ts +39 -0
- package/src/draw.ts +315 -122
- package/src/index.ts +1 -33
- package/src/peek.svelte +75 -0
- package/src/peek.svelte.d.ts +5 -0
- package/src/svelte-peek.ts +18 -0
- package/src/svelte.ts +3 -0
- package/src/svg.ts +15 -1
- package/src/tables.ts +47 -0
- package/src/vue.ts +133 -0
package/src/draw.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* what is invisible (opacity 0) to stay small.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import
|
|
12
|
+
import { blocParts } from './bloc'
|
|
13
|
+
import { type Axes, fnv1a, mulberry32, type Persona } from './identity'
|
|
13
14
|
import {
|
|
14
15
|
ACCESSORY_INK,
|
|
15
16
|
type Channel,
|
|
@@ -19,7 +20,10 @@ import {
|
|
|
19
20
|
type Expression,
|
|
20
21
|
FACES,
|
|
21
22
|
type FaceGeom,
|
|
23
|
+
HAZE,
|
|
22
24
|
INK,
|
|
25
|
+
type Variant,
|
|
26
|
+
WHEEL,
|
|
23
27
|
} from './tables'
|
|
24
28
|
|
|
25
29
|
export type Node = {
|
|
@@ -55,9 +59,12 @@ export type DrawOptions = {
|
|
|
55
59
|
title: string | false
|
|
56
60
|
/** Keep invisible nodes so an animator can write into them. */
|
|
57
61
|
live?: boolean
|
|
62
|
+
/** How the face is drawn. Default 'peek'. */
|
|
63
|
+
variant?: Variant
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
|
|
66
|
+
/** `key`, the tidied name, seeds Haze's blobs; without it, it hazes as ''. */
|
|
67
|
+
export type Drawable = Axes & { persona: Persona; key?: string }
|
|
61
68
|
|
|
62
69
|
/* The frame: viewBox -30 -30 400 400, the floor on its bottom edge. */
|
|
63
70
|
export const VB = [-30, -30, 400, 400] as const
|
|
@@ -70,7 +77,7 @@ export const smooth = (a: number, b: number, v: number) => {
|
|
|
70
77
|
const t = clamp((v - a) / (b - a), 0, 1)
|
|
71
78
|
return t * t * (3 - 2 * t)
|
|
72
79
|
}
|
|
73
|
-
const f2 = (v: number) => String(Math.round(v * 100) / 100)
|
|
80
|
+
export const f2 = (v: number) => String(Math.round(v * 100) / 100)
|
|
74
81
|
const f3 = (v: number) => String(Math.round(v * 1000) / 1000)
|
|
75
82
|
const strokeFor = (px: number) =>
|
|
76
83
|
px >= 96 ? 1 : px >= 56 ? 1.25 : px >= 40 ? 1.6 : px >= 30 ? 2 : 2.4
|
|
@@ -213,7 +220,12 @@ export function ringWalk(sh: FaceGeom): { rest: number; min: number } {
|
|
|
213
220
|
}
|
|
214
221
|
|
|
215
222
|
type Attrs = Record<string, string | number | false | undefined>
|
|
216
|
-
function n(
|
|
223
|
+
export function n(
|
|
224
|
+
tag: string,
|
|
225
|
+
key: string,
|
|
226
|
+
attrs: Attrs,
|
|
227
|
+
children: Node[] = [],
|
|
228
|
+
) {
|
|
217
229
|
const out: Record<string, string> = {}
|
|
218
230
|
for (const k in attrs) {
|
|
219
231
|
const v = attrs[k]
|
|
@@ -232,14 +244,11 @@ function prune(node: Node): Node | null {
|
|
|
232
244
|
|
|
233
245
|
export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
|
|
234
246
|
const sh: FaceGeom = FACES[who.face]
|
|
235
|
-
const { body: fill, deep } = COLORS[who.color]
|
|
236
247
|
const P = who.persona
|
|
237
248
|
const mul = P.mul as Partial<Record<Channel, number>>
|
|
238
249
|
const add = P.add as Partial<Record<Channel, number>>
|
|
239
250
|
const g = (c: Channel) => pose[c] * (mul[c] ?? 1) + (add[c] ?? 0)
|
|
240
|
-
const spread = P.spread
|
|
241
251
|
const id = o.id
|
|
242
|
-
const st = strokeFor(o.size)
|
|
243
252
|
const { baseY, R } = frameOf(sh)
|
|
244
253
|
const defs: Node[] = []
|
|
245
254
|
|
|
@@ -368,8 +377,262 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
|
|
|
368
377
|
const B = sh.bottom
|
|
369
378
|
const ty = baseY - alt * R
|
|
370
379
|
const sc = (v: number) => Math.round(v * 100000) / 100000
|
|
371
|
-
const
|
|
380
|
+
const [front, back] = (o.variant === 'bloc' ? blocParts : peekParts)({
|
|
381
|
+
who,
|
|
382
|
+
pose,
|
|
383
|
+
o,
|
|
384
|
+
defs,
|
|
385
|
+
g,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// the plates sit a hair out of register at large sizes
|
|
389
|
+
const off = (dx: number, dy: number) =>
|
|
390
|
+
riso ? `translate(${f2(dx * k * upp)} ${f2(dy * k * upp)})` : undefined
|
|
391
|
+
const av = n(
|
|
392
|
+
'g',
|
|
393
|
+
'av',
|
|
394
|
+
{
|
|
395
|
+
transform: `translate(${f2(x)} ${f2(ty)}) rotate(${f2(rot)} 170 ${B}) translate(170 ${B}) scale(${sc(sx)} ${sc(sy)}) translate(-170 ${-B})`,
|
|
396
|
+
filter: dim > 0.01 ? `url(#${id}-dim)` : undefined,
|
|
397
|
+
},
|
|
398
|
+
[
|
|
399
|
+
n(
|
|
400
|
+
'g',
|
|
401
|
+
'plateA',
|
|
402
|
+
{
|
|
403
|
+
transform: off(-0.55, 0.45),
|
|
404
|
+
filter: riso ? `url(#${id}-pA)` : undefined,
|
|
405
|
+
},
|
|
406
|
+
front,
|
|
407
|
+
),
|
|
408
|
+
n(
|
|
409
|
+
'g',
|
|
410
|
+
'plateB',
|
|
411
|
+
{
|
|
412
|
+
transform: off(0.6, -0.5),
|
|
413
|
+
filter: riso ? `url(#${id}-pB)` : undefined,
|
|
414
|
+
},
|
|
415
|
+
back,
|
|
416
|
+
),
|
|
417
|
+
],
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
// the thing in the corner: a dot lattice from the mark's vocabulary
|
|
421
|
+
const scene: Node[] = []
|
|
422
|
+
if (o.frame !== 'none') {
|
|
423
|
+
const bg =
|
|
424
|
+
o.frame === 'ink' ? INK.ink : o.frame === 'bone' ? INK.bone : INK.paper
|
|
425
|
+
scene.push(n('rect', 'bg', { ...VBr, fill: bg }))
|
|
426
|
+
const th = clamp(g('thing'), 0, 1)
|
|
427
|
+
const dots: Node[] = []
|
|
428
|
+
for (let j = 0; j < 3; j++)
|
|
429
|
+
for (let i = 0; i < 3; i++) {
|
|
430
|
+
const q = j * 3 + i
|
|
431
|
+
const kk = smooth(q / 12, q / 12 + 0.35, th)
|
|
432
|
+
dots.push(
|
|
433
|
+
n('circle', `lattice.${q}`, {
|
|
434
|
+
cx: 312 + i * 18,
|
|
435
|
+
cy: 2 + j * 18,
|
|
436
|
+
r: f2(1.2 + 2.2 * kk),
|
|
437
|
+
opacity: f3(kk * 0.55),
|
|
438
|
+
}),
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
scene.push(
|
|
442
|
+
n('g', 'lattice', { fill: o.frame === 'ink' ? INK.bone : INK.ink }, dots),
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
scene.push(av)
|
|
446
|
+
if (grainy)
|
|
447
|
+
scene.push(
|
|
448
|
+
n('rect', 'grain', {
|
|
449
|
+
...VBr,
|
|
450
|
+
filter: `url(#${id}-grain)`,
|
|
451
|
+
opacity: o.frame === 'ink' ? 0.3 : 0.16,
|
|
452
|
+
'pointer-events': 'none',
|
|
453
|
+
}),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
const root = n(
|
|
457
|
+
'svg',
|
|
458
|
+
'root',
|
|
459
|
+
{
|
|
460
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
461
|
+
viewBox: VB.join(' '),
|
|
462
|
+
width: o.size,
|
|
463
|
+
height: o.size,
|
|
464
|
+
role: o.title === false ? undefined : 'img',
|
|
465
|
+
'aria-label': o.title === false ? undefined : o.title,
|
|
466
|
+
'aria-hidden': o.title === false ? 'true' : undefined,
|
|
467
|
+
},
|
|
468
|
+
[
|
|
469
|
+
n('defs', 'defs', {}, defs),
|
|
470
|
+
n('g', 'frame', { 'clip-path': `url(#${id}-f)` }, scene),
|
|
471
|
+
],
|
|
472
|
+
)
|
|
473
|
+
return o.live ? root : (prune(root) ?? root)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** What a variant draws from: the defs it may add to, and the pose reader. */
|
|
477
|
+
export type Parts = {
|
|
478
|
+
who: Drawable
|
|
479
|
+
pose: Pose
|
|
480
|
+
o: DrawOptions
|
|
481
|
+
defs: Node[]
|
|
482
|
+
/** A channel with the persona applied. */
|
|
483
|
+
g: (c: Channel) => number
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/*
|
|
487
|
+
* Haze, from the Haze sheet: the body is its own ink plus three soft radial
|
|
488
|
+
* blobs, cut to the outline. A deep one at the bottom, a tint from a hue
|
|
489
|
+
* neighbor low on the outer edge, a glow at the top. Where they sit comes
|
|
490
|
+
* from the name, mulberry32(fnv1a('haze:' + key)), so two faces of one
|
|
491
|
+
* color haze apart.
|
|
492
|
+
* Blob sizes are in body widths; the light level, used below 32px, keeps
|
|
493
|
+
* the face nearly peek where the haze would only be mud.
|
|
494
|
+
*/
|
|
495
|
+
const HAZE_LEVEL = {
|
|
496
|
+
medium: {
|
|
497
|
+
deep: [0.502, 0.224],
|
|
498
|
+
glow: [0.749, 0.337],
|
|
499
|
+
tint: [0.4, 0.5],
|
|
500
|
+
tintStops: [
|
|
501
|
+
[0, 0.878],
|
|
502
|
+
[0.15, 0.878],
|
|
503
|
+
[0.618, 0.396],
|
|
504
|
+
],
|
|
505
|
+
},
|
|
506
|
+
light: {
|
|
507
|
+
deep: [0.298, 0.133],
|
|
508
|
+
glow: [0.549, 0.247],
|
|
509
|
+
tint: [0.31, 0.387],
|
|
510
|
+
tintStops: [
|
|
511
|
+
[0, 0.549],
|
|
512
|
+
[0.55, 0.247],
|
|
513
|
+
],
|
|
514
|
+
},
|
|
515
|
+
} as const
|
|
516
|
+
|
|
517
|
+
function hazePaint(who: Drawable, o: DrawOptions): Node[] {
|
|
518
|
+
const sh: FaceGeom = FACES[who.face]
|
|
519
|
+
const id = o.id
|
|
520
|
+
const L = HAZE_LEVEL[o.size < 32 ? 'light' : 'medium']
|
|
521
|
+
// the body's box: the crown grown by its radius, down to the bottom
|
|
522
|
+
const xs = sh.crown.poly.map((p) => p[0])
|
|
523
|
+
const ys = sh.crown.poly.map((p) => p[1])
|
|
524
|
+
const x0 = Math.min(...xs) - sh.crown.r
|
|
525
|
+
const y0 = Math.min(...ys) - sh.crown.r
|
|
526
|
+
const W = Math.max(...xs) + sh.crown.r - x0
|
|
527
|
+
const H = sh.bottom - y0
|
|
528
|
+
|
|
529
|
+
// the sheet's own seed and draw order, fitted to its 12 names: never
|
|
530
|
+
// reorder these lines. The tint walks one track, low and out to high and in.
|
|
531
|
+
const rnd = mulberry32(fnv1a(`haze:${who.key ?? ''}`))
|
|
532
|
+
const warm = rnd() < 0.5
|
|
533
|
+
const left = rnd() < 0.5
|
|
534
|
+
const t = rnd()
|
|
535
|
+
const tintX = 0.04 + 0.174 * t
|
|
536
|
+
const tintY = 0.649 + 0.202 * t
|
|
537
|
+
const glowX = 0.38 - 0.12 * rnd()
|
|
538
|
+
const glowY = 0.12 + 0.099 * rnd()
|
|
539
|
+
const deepX = 0.251 - 0.151 * rnd()
|
|
540
|
+
const tintA = 90 - 180 * rnd()
|
|
541
|
+
|
|
542
|
+
const at = (inset: number, onLeft: boolean) =>
|
|
543
|
+
x0 + W * (onLeft ? inset : 1 - inset)
|
|
544
|
+
const i = WHEEL.indexOf(who.color)
|
|
545
|
+
const near = WHEEL[(i + (warm ? WHEEL.length - 1 : 1)) % WHEEL.length]!
|
|
546
|
+
const { body, deep } = COLORS[who.color]
|
|
547
|
+
const blob = (
|
|
548
|
+
k: string,
|
|
549
|
+
color: string,
|
|
550
|
+
[cx, cy, a, rx, ry]: [number, number, number, number, number],
|
|
551
|
+
stops: readonly (readonly [number, number])[],
|
|
552
|
+
) =>
|
|
553
|
+
n(
|
|
554
|
+
'radialGradient',
|
|
555
|
+
`haze.${k}`,
|
|
556
|
+
{
|
|
557
|
+
id: `${id}-h${k[0]}`,
|
|
558
|
+
gradientUnits: 'userSpaceOnUse',
|
|
559
|
+
cx: 0,
|
|
560
|
+
cy: 0,
|
|
561
|
+
r: 1,
|
|
562
|
+
gradientTransform: `translate(${f2(cx)} ${f2(cy)}) rotate(${f2(a)}) scale(${f2(rx)} ${f2(ry)})`,
|
|
563
|
+
},
|
|
564
|
+
[...stops, [1, 0] as const].map(([at, op], j) =>
|
|
565
|
+
n('stop', `haze.${k}.${j}`, {
|
|
566
|
+
offset: at,
|
|
567
|
+
'stop-color': color,
|
|
568
|
+
'stop-opacity': op,
|
|
569
|
+
}),
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
const fade = ([a, b]: readonly [number, number]) =>
|
|
573
|
+
[
|
|
574
|
+
[0, a],
|
|
575
|
+
[0.55, b],
|
|
576
|
+
] as const
|
|
577
|
+
const blobs = [
|
|
578
|
+
blob(
|
|
579
|
+
'deep',
|
|
580
|
+
deep,
|
|
581
|
+
[at(deepX, !left), y0 + H * 0.95, 0, W * 0.5, W * 0.35],
|
|
582
|
+
fade(L.deep),
|
|
583
|
+
),
|
|
584
|
+
blob(
|
|
585
|
+
'tint',
|
|
586
|
+
HAZE[near].tint,
|
|
587
|
+
[at(tintX, left), y0 + H * tintY, tintA, W * L.tint[0], W * L.tint[1]],
|
|
588
|
+
L.tintStops,
|
|
589
|
+
),
|
|
590
|
+
blob(
|
|
591
|
+
'glow',
|
|
592
|
+
HAZE[who.color].glow,
|
|
593
|
+
[at(glowX, !left), y0 + H * glowY, 0, W * 0.475, W * 0.356],
|
|
594
|
+
fade(L.glow),
|
|
595
|
+
),
|
|
596
|
+
]
|
|
597
|
+
const shape = (key: string, paint: string) =>
|
|
372
598
|
'd' in sh.body
|
|
599
|
+
? n('path', key, {
|
|
600
|
+
d: sh.body.d,
|
|
601
|
+
fill: paint,
|
|
602
|
+
stroke: sh.body.stroke ? paint : undefined,
|
|
603
|
+
'stroke-width': sh.body.stroke,
|
|
604
|
+
'stroke-linejoin': sh.body.stroke ? 'round' : undefined,
|
|
605
|
+
})
|
|
606
|
+
: n('circle', key, {
|
|
607
|
+
cx: sh.body.circle[0],
|
|
608
|
+
cy: sh.body.circle[1],
|
|
609
|
+
r: sh.body.circle[2],
|
|
610
|
+
fill: paint,
|
|
611
|
+
})
|
|
612
|
+
return [
|
|
613
|
+
...blobs,
|
|
614
|
+
n('g', 'haze', { id: `${id}-hz` }, [
|
|
615
|
+
shape('haze.body', body),
|
|
616
|
+
...['deep', 'tint', 'glow'].map((k) =>
|
|
617
|
+
shape(`haze.${k}.fill`, `url(#${id}-h${k[0]})`),
|
|
618
|
+
),
|
|
619
|
+
]),
|
|
620
|
+
]
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** The peek face, and Haze: the same face with a hazed body. */
|
|
624
|
+
function peekParts({ who, pose, o, defs, g }: Parts): [Node[], Node[]] {
|
|
625
|
+
const sh: FaceGeom = FACES[who.face]
|
|
626
|
+
const { body: fill, deep } = COLORS[who.color]
|
|
627
|
+
const P = who.persona
|
|
628
|
+
const spread = P.spread
|
|
629
|
+
const id = o.id
|
|
630
|
+
const st = strokeFor(o.size)
|
|
631
|
+
const haze = o.variant === 'haze'
|
|
632
|
+
if (haze) defs.push(...hazePaint(who, o))
|
|
633
|
+
const bodyNode = haze
|
|
634
|
+
? n('use', 'body', { href: `#${id}-hz` })
|
|
635
|
+
: 'd' in sh.body
|
|
373
636
|
? n('path', 'body', {
|
|
374
637
|
d: sh.body.d,
|
|
375
638
|
fill,
|
|
@@ -475,25 +738,47 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
|
|
|
475
738
|
const lrx = rx * 1.55
|
|
476
739
|
const lry = ry * 1.15
|
|
477
740
|
const top = ry + 1.6 - lower * ry * 1.5
|
|
741
|
+
// a bead has no white to show where it looks, so the whole eye leans
|
|
742
|
+
const [etx, ety] = bead
|
|
743
|
+
? [ex + side * spread + gx * 6, ey + gy * 6]
|
|
744
|
+
: [ex + side * spread, ey]
|
|
745
|
+
const lids = [
|
|
746
|
+
n('rect', `${kk}.lid`, {
|
|
747
|
+
x: f2(-rx * 2.2),
|
|
748
|
+
y: f2(-ry * 3),
|
|
749
|
+
width: f2(rx * 4.4),
|
|
750
|
+
height: f2(Math.max(0, edge + ry * 3)),
|
|
751
|
+
transform: `rotate(${f2(a)})`,
|
|
752
|
+
fill,
|
|
753
|
+
}),
|
|
754
|
+
n('ellipse', `${kk}.lower`, {
|
|
755
|
+
cx: 0,
|
|
756
|
+
cy: f2(top + lry),
|
|
757
|
+
rx: f2(lrx),
|
|
758
|
+
ry: f2(lry),
|
|
759
|
+
fill,
|
|
760
|
+
}),
|
|
761
|
+
]
|
|
762
|
+
// Haze: the lids cut the body's own paint, so they never read as patches
|
|
763
|
+
if (haze)
|
|
764
|
+
defs.push(n('clipPath', `${kk}.lidshape`, { id: `${id}-s${i}` }, lids))
|
|
478
765
|
const parts: Node[] = [
|
|
479
766
|
n('g', `${kk}.inner`, { 'clip-path': `url(#${id}-e${i})` }, inner),
|
|
480
|
-
n(
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}),
|
|
496
|
-
]),
|
|
767
|
+
n(
|
|
768
|
+
'g',
|
|
769
|
+
`${kk}.lids`,
|
|
770
|
+
{ 'clip-path': `url(#${id}-l${i})` },
|
|
771
|
+
haze
|
|
772
|
+
? [
|
|
773
|
+
n('g', `${kk}.lidpaint`, { 'clip-path': `url(#${id}-s${i})` }, [
|
|
774
|
+
n('use', `${kk}.lidbody`, {
|
|
775
|
+
href: `#${id}-hz`,
|
|
776
|
+
transform: `translate(${f2(-etx)} ${f2(-ety)})`,
|
|
777
|
+
}),
|
|
778
|
+
]),
|
|
779
|
+
]
|
|
780
|
+
: lids,
|
|
781
|
+
),
|
|
497
782
|
]
|
|
498
783
|
// a rimmed eye inks its lid edges too, so the outline follows the lids
|
|
499
784
|
if (who.eyes === 'ring')
|
|
@@ -538,17 +823,7 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
|
|
|
538
823
|
opacity: co > 0.01 ? f2(co) : 0,
|
|
539
824
|
}),
|
|
540
825
|
)
|
|
541
|
-
return n(
|
|
542
|
-
'g',
|
|
543
|
-
kk,
|
|
544
|
-
{
|
|
545
|
-
// a bead has no white to show where it looks, so the whole eye leans
|
|
546
|
-
transform: bead
|
|
547
|
-
? `translate(${f2(ex + side * spread + gx * 6)} ${f2(ey + gy * 6)})`
|
|
548
|
-
: `translate(${f2(ex + side * spread)} ${ey})`,
|
|
549
|
-
},
|
|
550
|
-
parts,
|
|
551
|
-
)
|
|
826
|
+
return n('g', kk, { transform: `translate(${f2(etx)} ${f2(ety)})` }, parts)
|
|
552
827
|
})
|
|
553
828
|
|
|
554
829
|
// brows
|
|
@@ -980,90 +1255,8 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
|
|
|
980
1255
|
)
|
|
981
1256
|
}
|
|
982
1257
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
'g',
|
|
988
|
-
'av',
|
|
989
|
-
{
|
|
990
|
-
transform: `translate(${f2(x)} ${f2(ty)}) rotate(${f2(rot)} 170 ${B}) translate(170 ${B}) scale(${sc(sx)} ${sc(sy)}) translate(-170 ${-B})`,
|
|
991
|
-
filter: dim > 0.01 ? `url(#${id}-dim)` : undefined,
|
|
992
|
-
},
|
|
993
|
-
[
|
|
994
|
-
n(
|
|
995
|
-
'g',
|
|
996
|
-
'plateA',
|
|
997
|
-
{
|
|
998
|
-
transform: off(-0.55, 0.45),
|
|
999
|
-
filter: riso ? `url(#${id}-pA)` : undefined,
|
|
1000
|
-
},
|
|
1001
|
-
[bodyNode, ...cheeks, trait],
|
|
1002
|
-
),
|
|
1003
|
-
n(
|
|
1004
|
-
'g',
|
|
1005
|
-
'plateB',
|
|
1006
|
-
{
|
|
1007
|
-
transform: off(0.6, -0.5),
|
|
1008
|
-
filter: riso ? `url(#${id}-pB)` : undefined,
|
|
1009
|
-
},
|
|
1010
|
-
[...eyes, ...brows, mouth, ...wardrobe],
|
|
1011
|
-
),
|
|
1012
|
-
],
|
|
1013
|
-
)
|
|
1014
|
-
|
|
1015
|
-
// the thing in the corner: a dot lattice from the mark's vocabulary
|
|
1016
|
-
const scene: Node[] = []
|
|
1017
|
-
if (o.frame !== 'none') {
|
|
1018
|
-
const bg =
|
|
1019
|
-
o.frame === 'ink' ? INK.ink : o.frame === 'bone' ? INK.bone : INK.paper
|
|
1020
|
-
scene.push(n('rect', 'bg', { ...VBr, fill: bg }))
|
|
1021
|
-
const th = clamp(g('thing'), 0, 1)
|
|
1022
|
-
const dots: Node[] = []
|
|
1023
|
-
for (let j = 0; j < 3; j++)
|
|
1024
|
-
for (let i = 0; i < 3; i++) {
|
|
1025
|
-
const q = j * 3 + i
|
|
1026
|
-
const kk = smooth(q / 12, q / 12 + 0.35, th)
|
|
1027
|
-
dots.push(
|
|
1028
|
-
n('circle', `lattice.${q}`, {
|
|
1029
|
-
cx: 312 + i * 18,
|
|
1030
|
-
cy: 2 + j * 18,
|
|
1031
|
-
r: f2(1.2 + 2.2 * kk),
|
|
1032
|
-
opacity: f3(kk * 0.55),
|
|
1033
|
-
}),
|
|
1034
|
-
)
|
|
1035
|
-
}
|
|
1036
|
-
scene.push(
|
|
1037
|
-
n('g', 'lattice', { fill: o.frame === 'ink' ? INK.bone : INK.ink }, dots),
|
|
1038
|
-
)
|
|
1039
|
-
}
|
|
1040
|
-
scene.push(av)
|
|
1041
|
-
if (grainy)
|
|
1042
|
-
scene.push(
|
|
1043
|
-
n('rect', 'grain', {
|
|
1044
|
-
...VBr,
|
|
1045
|
-
filter: `url(#${id}-grain)`,
|
|
1046
|
-
opacity: o.frame === 'ink' ? 0.3 : 0.16,
|
|
1047
|
-
'pointer-events': 'none',
|
|
1048
|
-
}),
|
|
1049
|
-
)
|
|
1050
|
-
|
|
1051
|
-
const root = n(
|
|
1052
|
-
'svg',
|
|
1053
|
-
'root',
|
|
1054
|
-
{
|
|
1055
|
-
xmlns: 'http://www.w3.org/2000/svg',
|
|
1056
|
-
viewBox: VB.join(' '),
|
|
1057
|
-
width: o.size,
|
|
1058
|
-
height: o.size,
|
|
1059
|
-
role: o.title === false ? undefined : 'img',
|
|
1060
|
-
'aria-label': o.title === false ? undefined : o.title,
|
|
1061
|
-
'aria-hidden': o.title === false ? 'true' : undefined,
|
|
1062
|
-
},
|
|
1063
|
-
[
|
|
1064
|
-
n('defs', 'defs', {}, defs),
|
|
1065
|
-
n('g', 'frame', { 'clip-path': `url(#${id}-f)` }, scene),
|
|
1066
|
-
],
|
|
1067
|
-
)
|
|
1068
|
-
return o.live ? root : (prune(root) ?? root)
|
|
1258
|
+
return [
|
|
1259
|
+
[bodyNode, ...cheeks, trait],
|
|
1260
|
+
[...eyes, ...brows, mouth, ...wardrobe],
|
|
1261
|
+
]
|
|
1069
1262
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,35 +1,3 @@
|
|
|
1
|
-
export
|
|
2
|
-
/** Low level: the drawing tree that toSvg and <Peek> both render. */
|
|
3
|
-
export { draw } from './draw'
|
|
4
|
-
export type { Axes, Identity, Persona } from './identity'
|
|
5
|
-
export { AXES, identify, LISTS, tidy } from './identity'
|
|
1
|
+
export * from './core'
|
|
6
2
|
export type { PeekProps } from './peek'
|
|
7
3
|
export { Peek } from './peek'
|
|
8
|
-
export type { PeekOptions } from './svg'
|
|
9
|
-
export { settle, toSvg } from './svg'
|
|
10
|
-
export type {
|
|
11
|
-
AccessorySlot,
|
|
12
|
-
Axis,
|
|
13
|
-
Brows,
|
|
14
|
-
Channel,
|
|
15
|
-
Cheeks,
|
|
16
|
-
Color,
|
|
17
|
-
Expression,
|
|
18
|
-
Eyes,
|
|
19
|
-
Eyewear,
|
|
20
|
-
Face,
|
|
21
|
-
Headwear,
|
|
22
|
-
Mouth,
|
|
23
|
-
Neckwear,
|
|
24
|
-
Trait,
|
|
25
|
-
} from './tables'
|
|
26
|
-
export {
|
|
27
|
-
ACCESSORIES,
|
|
28
|
-
COLORS,
|
|
29
|
-
EXPRESSIONS,
|
|
30
|
-
FACES,
|
|
31
|
-
GROUPS,
|
|
32
|
-
LATEST,
|
|
33
|
-
PARTS,
|
|
34
|
-
VERSIONS,
|
|
35
|
-
} from './tables'
|
package/src/peek.svelte
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
<Peek name="Ada" /> for Svelte 5: the same tree and the same rules as the
|
|
3
|
+
React component in peek.tsx. ids come from $props.id(), so the server and
|
|
4
|
+
the client agree. The inside of the svg is the exact markup toSvg writes.
|
|
5
|
+
|
|
6
|
+
With `animate`, the markup stays in the pose the component mounted with and
|
|
7
|
+
the shared loop writes every frame straight to the DOM, so a change of
|
|
8
|
+
expression or gaze never reaches Svelte.
|
|
9
|
+
-->
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
import { untrack } from 'svelte'
|
|
12
|
+
import {
|
|
13
|
+
draw,
|
|
14
|
+
Live,
|
|
15
|
+
type PeekProps,
|
|
16
|
+
restPose,
|
|
17
|
+
serialize,
|
|
18
|
+
settle,
|
|
19
|
+
} from './svelte-peek'
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
name,
|
|
23
|
+
gaze,
|
|
24
|
+
animate = false,
|
|
25
|
+
class: className,
|
|
26
|
+
style,
|
|
27
|
+
...rest
|
|
28
|
+
}: PeekProps = $props()
|
|
29
|
+
const uid = $props.id()
|
|
30
|
+
const id = `peek${uid.replace(/[^\w-]/g, '')}`
|
|
31
|
+
const fixedOf = (g: PeekProps['gaze']) =>
|
|
32
|
+
Array.isArray(g) ? (g as readonly [number, number]) : undefined
|
|
33
|
+
const first = untrack(() => ({
|
|
34
|
+
expression: rest.expression ?? 'normal',
|
|
35
|
+
gaze: fixedOf(gaze),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const s = $derived(settle(name, { ...rest, gaze: fixedOf(gaze), id }, animate))
|
|
39
|
+
const tree = $derived(
|
|
40
|
+
draw(
|
|
41
|
+
s.who,
|
|
42
|
+
animate ? restPose(s.who, first.expression, first.gaze) : s.pose,
|
|
43
|
+
s.opts,
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
const inner = $derived(tree.children.map(serialize).join(''))
|
|
47
|
+
const expression = $derived(rest.expression ?? 'normal')
|
|
48
|
+
// live: the face only restarts when what the face is changes
|
|
49
|
+
const sig = $derived(JSON.stringify([s.who, s.opts]))
|
|
50
|
+
const gazeKey = $derived(String(gaze ?? ''))
|
|
51
|
+
|
|
52
|
+
let svg: SVGSVGElement | undefined
|
|
53
|
+
let live: Live | null = null
|
|
54
|
+
|
|
55
|
+
$effect(() => {
|
|
56
|
+
void sig
|
|
57
|
+
if (!animate || !svg) return
|
|
58
|
+
const l = untrack(() => new Live(svg!, tree, s.who, s.opts, expression, gaze))
|
|
59
|
+
live = l
|
|
60
|
+
return () => {
|
|
61
|
+
l.destroy()
|
|
62
|
+
live = null
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
$effect(() => {
|
|
66
|
+
live?.setExpression(expression)
|
|
67
|
+
})
|
|
68
|
+
$effect(() => {
|
|
69
|
+
void gazeKey
|
|
70
|
+
untrack(() => live?.setGaze(gaze))
|
|
71
|
+
})
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<!-- spread, so an unset class is no attribute rather than class="" -->
|
|
75
|
+
<svg bind:this={svg} {...tree.attrs} {...{ class: className, style }}>{@html inner}</svg>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* What peek.svelte imports. Built as its own file, since the component ships
|
|
2
|
+
* as source and the app's bundler compiles it. */
|
|
3
|
+
import type { Gaze } from './draw'
|
|
4
|
+
import type { PeekOptions } from './svg'
|
|
5
|
+
|
|
6
|
+
export { Live } from './animate'
|
|
7
|
+
export { draw, restPose } from './draw'
|
|
8
|
+
export { serialize, settle } from './svg'
|
|
9
|
+
|
|
10
|
+
export type PeekProps = Omit<PeekOptions, 'gaze' | 'id'> & {
|
|
11
|
+
name: string
|
|
12
|
+
/** x and y in -1..1, or 'pointer' to follow the pointer (animate only). */
|
|
13
|
+
gaze?: Gaze | 'pointer'
|
|
14
|
+
/** Bring it to life after mount. Off under prefers-reduced-motion. */
|
|
15
|
+
animate?: boolean
|
|
16
|
+
class?: string
|
|
17
|
+
style?: string
|
|
18
|
+
}
|
package/src/svelte.ts
ADDED
package/src/svg.ts
CHANGED
|
@@ -13,7 +13,13 @@ import {
|
|
|
13
13
|
restPose,
|
|
14
14
|
} from './draw'
|
|
15
15
|
import { type Axes, fnv1a, type Identity, identify } from './identity'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
ACCESSORIES,
|
|
18
|
+
type AccessorySlot,
|
|
19
|
+
type Expression,
|
|
20
|
+
VARIANTS,
|
|
21
|
+
type Variant,
|
|
22
|
+
} from './tables'
|
|
17
23
|
|
|
18
24
|
export type PeekOptions = Partial<Axes> & {
|
|
19
25
|
/** px. Drives the stroke ramp (thicker below 96) and the riso print. */
|
|
@@ -30,6 +36,8 @@ export type PeekOptions = Partial<Axes> & {
|
|
|
30
36
|
riso?: boolean
|
|
31
37
|
/** Pin a style version. Default the latest. */
|
|
32
38
|
version?: number
|
|
39
|
+
/** How the face is drawn: 'peek', 'haze' or 'bloc'. Never who it is. */
|
|
40
|
+
variant?: Variant
|
|
33
41
|
/** Prefix for ids inside the SVG. Default derived from everything drawn,
|
|
34
42
|
* so two different avatars inlined in one page never share defs. */
|
|
35
43
|
id?: string
|
|
@@ -65,12 +73,18 @@ export function settle(
|
|
|
65
73
|
neckwear: wear(o, base, 'neckwear'),
|
|
66
74
|
}
|
|
67
75
|
const pose = restPose(who, o.expression ?? 'normal', o.gaze)
|
|
76
|
+
// an unknown variant from untyped callers draws peek
|
|
77
|
+
const variant = VARIANTS.includes(o.variant as Variant)
|
|
78
|
+
? (o.variant as Variant)
|
|
79
|
+
: 'peek'
|
|
68
80
|
const look = {
|
|
69
81
|
size: o.size ?? 64,
|
|
70
82
|
frame: o.frame ?? 'ink',
|
|
71
83
|
square: o.square ?? true,
|
|
72
84
|
riso: o.riso ?? false,
|
|
73
85
|
live,
|
|
86
|
+
// left out for peek, so its ids and bytes stay as they were
|
|
87
|
+
...(variant !== 'peek' && { variant }),
|
|
74
88
|
}
|
|
75
89
|
// Keep the byte-level peek@1 contract for faces without accessories.
|
|
76
90
|
const { eyewear, headwear, neckwear, ...legacy } = who
|