@glissade/scene 0.23.0-pre.5 → 0.24.0-pre.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/dist/describe.d.ts +30 -2
- package/dist/describe.js +40 -11
- package/dist/examples.d.ts +38 -0
- package/dist/examples.js +217 -0
- package/dist/motion.js +1 -170
- package/dist/motionPath.js +171 -0
- package/package.json +9 -3
package/dist/describe.d.ts
CHANGED
|
@@ -51,6 +51,13 @@ interface DescribedNode {
|
|
|
51
51
|
props: {
|
|
52
52
|
[prop: string]: DescribedProp;
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* Runnable, drift-guarded code snippets for this node — present ONLY when
|
|
56
|
+
* `describe({ examples: true })` is called AND `@glissade/scene/examples` has
|
|
57
|
+
* been imported (it registers the corpus). Each string is an executable example
|
|
58
|
+
* the doctest harness runs, so it can't go stale (§0.24 onboarding).
|
|
59
|
+
*/
|
|
60
|
+
examples?: readonly string[];
|
|
54
61
|
/**
|
|
55
62
|
* What this node's `position` points at WITHOUT an explicit `anchor` (its
|
|
56
63
|
* legacy origin): 'center' for shapes, 'baseline-left' for Text, etc. Override
|
|
@@ -68,6 +75,8 @@ interface DescribedNode {
|
|
|
68
75
|
interface DescribedBuilderMethod {
|
|
69
76
|
name: string;
|
|
70
77
|
signature: string;
|
|
78
|
+
/** Runnable example snippets — see {@link DescribedNode.examples}. */
|
|
79
|
+
examples?: readonly string[];
|
|
71
80
|
}
|
|
72
81
|
/**
|
|
73
82
|
* One helper/factory in the manifest — the broader builder API beyond the node
|
|
@@ -87,6 +96,8 @@ interface DescribedHelper {
|
|
|
87
96
|
import: string;
|
|
88
97
|
/** A minimal signature/usage string showing the call shape. */
|
|
89
98
|
usage: string;
|
|
99
|
+
/** Runnable example snippets — see {@link DescribedNode.examples}. */
|
|
100
|
+
examples?: readonly string[];
|
|
90
101
|
}
|
|
91
102
|
/** The full machine-readable manifest `describe()` returns. */
|
|
92
103
|
interface ApiManifest {
|
|
@@ -117,6 +128,23 @@ interface ApiManifest {
|
|
|
117
128
|
* targets, enumerate the ValueType + easing registries, and curate the builder /
|
|
118
129
|
* subpath surface. JSON-serializable; safe to call any number of times.
|
|
119
130
|
*/
|
|
120
|
-
|
|
131
|
+
interface DescribeOptions {
|
|
132
|
+
/**
|
|
133
|
+
* Attach runnable example snippets (per node / builder method / helper) from
|
|
134
|
+
* the registered corpus (§0.24 onboarding). The corpus is registered by
|
|
135
|
+
* importing `@glissade/scene/examples`, kept OFF the base index so it costs
|
|
136
|
+
* nothing when unused. With no corpus registered, `examples: true` is a no-op
|
|
137
|
+
* (the manifest is byte-identical to `describe()`).
|
|
138
|
+
*/
|
|
139
|
+
examples?: boolean;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Register the runnable-example corpus — called by `@glissade/scene/examples` on
|
|
143
|
+
* import. A registration hook (not a static import) so `describe()` stays lean.
|
|
144
|
+
*/
|
|
145
|
+
declare function registerExamples(corpus: {
|
|
146
|
+
readonly [key: string]: readonly string[];
|
|
147
|
+
}): void;
|
|
148
|
+
declare function describe(opts?: DescribeOptions): ApiManifest;
|
|
121
149
|
//#endregion
|
|
122
|
-
export { ApiManifest, DescribedBuilderMethod, DescribedHelper, DescribedNode, DescribedProp, describe };
|
|
150
|
+
export { ApiManifest, DescribeOptions, DescribedBuilderMethod, DescribedHelper, DescribedNode, DescribedProp, describe, registerExamples };
|
package/dist/describe.js
CHANGED
|
@@ -22,7 +22,7 @@ import { easings, listValueTypes } from "@glissade/core";
|
|
|
22
22
|
* never pulled onto the base embed path — a scene that never calls `describe()`
|
|
23
23
|
* pays zero bytes for it.
|
|
24
24
|
*/
|
|
25
|
-
const RAW_VERSION = "0.
|
|
25
|
+
const RAW_VERSION = "0.24.0-pre.0";
|
|
26
26
|
const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
|
|
27
27
|
/** Arity of a value type's numeric repr: vec2/vec2-arc → 2, number → 1; others (color/paint/path/string/boolean) carry no scalar arity. */
|
|
28
28
|
function arityOf(type) {
|
|
@@ -390,31 +390,60 @@ const SUBPATHS = {
|
|
|
390
390
|
"@glissade/scene/path": "SVG geometry: pathFromSvg / parseSvgPathData — parse an SVG `d` string into a PathValue for Path.data."
|
|
391
391
|
};
|
|
392
392
|
/**
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
393
|
+
* The registered runnable-example corpus, keyed by describe-key (node type name /
|
|
394
|
+
* builder method name / helper name). Populated by `@glissade/scene/examples` at
|
|
395
|
+
* import time via {@link registerExamples} — describe NEVER statically imports the
|
|
396
|
+
* corpus, so the base index and the IIFE never pay for it (the value-type-registry
|
|
397
|
+
* pattern). Empty until that subpath is loaded.
|
|
397
398
|
*/
|
|
398
|
-
|
|
399
|
+
let exampleCorpus = {};
|
|
400
|
+
/**
|
|
401
|
+
* Register the runnable-example corpus — called by `@glissade/scene/examples` on
|
|
402
|
+
* import. A registration hook (not a static import) so `describe()` stays lean.
|
|
403
|
+
*/
|
|
404
|
+
function registerExamples(corpus) {
|
|
405
|
+
exampleCorpus = corpus;
|
|
406
|
+
}
|
|
407
|
+
function describe(opts = {}) {
|
|
408
|
+
const withEx = opts.examples === true && Object.keys(exampleCorpus).length > 0;
|
|
409
|
+
const ex = (key) => {
|
|
410
|
+
const e = withEx ? exampleCorpus[key] : void 0;
|
|
411
|
+
return e && e.length > 0 ? { examples: e } : void 0;
|
|
412
|
+
};
|
|
399
413
|
const nodes = {};
|
|
400
|
-
for (const [name, factory] of Object.entries(NODE_FACTORIES)) nodes[name] =
|
|
414
|
+
for (const [name, factory] of Object.entries(NODE_FACTORIES)) nodes[name] = {
|
|
415
|
+
...describeNode(factory(), name),
|
|
416
|
+
...ex(name)
|
|
417
|
+
};
|
|
401
418
|
const layout = describeLayoutNode();
|
|
402
419
|
for (const name of [
|
|
403
420
|
"Layout",
|
|
404
421
|
"Stack",
|
|
405
422
|
"Row",
|
|
406
423
|
"Column"
|
|
407
|
-
])
|
|
424
|
+
]) {
|
|
425
|
+
const e = ex(name);
|
|
426
|
+
nodes[name] = e ? {
|
|
427
|
+
...layout,
|
|
428
|
+
...e
|
|
429
|
+
} : layout;
|
|
430
|
+
}
|
|
408
431
|
return {
|
|
409
432
|
version: PACKAGE_VERSION,
|
|
410
433
|
nodes,
|
|
411
434
|
valueTypes: listValueTypes(),
|
|
412
435
|
easings: Object.keys(easings),
|
|
413
|
-
builder: { methods: BUILDER_METHODS
|
|
414
|
-
|
|
436
|
+
builder: { methods: withEx ? BUILDER_METHODS.map((m) => ({
|
|
437
|
+
...m,
|
|
438
|
+
...ex(m.name)
|
|
439
|
+
})) : BUILDER_METHODS },
|
|
440
|
+
helpers: withEx ? HELPERS.map((h) => ({
|
|
441
|
+
...h,
|
|
442
|
+
...ex(h.name)
|
|
443
|
+
})) : HELPERS,
|
|
415
444
|
createScene: "createScene({ size: { w, h }, children: Node[] }): Scene — media assets are declared on the Timeline document: timeline({ assets: { <id>: { kind: 'image'|'video', url } } }); an Image/Video node's `assetId` names an entry here.",
|
|
416
445
|
subpaths: SUBPATHS
|
|
417
446
|
};
|
|
418
447
|
}
|
|
419
448
|
//#endregion
|
|
420
|
-
export { describe };
|
|
449
|
+
export { describe, registerExamples };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//#region src/examples.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `@glissade/scene/examples` — the runnable example corpus (§0.24 onboarding,
|
|
4
|
+
* card `8jQ9rNqStGDL`). The single highest agent-onboarding cost is STALE
|
|
5
|
+
* examples: prose that drifts from the runtime, which a cold agent can't
|
|
6
|
+
* glance-test. The fix is to make canonical examples EXECUTABLE and surfaced from
|
|
7
|
+
* the same place the doctest harness runs them, so they can't go stale.
|
|
8
|
+
*
|
|
9
|
+
* Each {@link ApiExample} carries a copy-pasteable `code` string (what
|
|
10
|
+
* `describe({ examples: true })` surfaces) AND an executable `run` thunk against
|
|
11
|
+
* the REAL API (what `examples.test.ts` asserts never throws). The two sit
|
|
12
|
+
* adjacent and are kept in lockstep; the `run` guard is what fails CI the moment
|
|
13
|
+
* an example's API drifts (a renamed export, a changed shape).
|
|
14
|
+
*
|
|
15
|
+
* Tree-shaking: this module is OFF the base scene index (its own subpath entry),
|
|
16
|
+
* so a scene that never imports it pays zero bytes. Importing it REGISTERS the
|
|
17
|
+
* corpus with `describe()` via {@link registerExamples} (the value-type-registry
|
|
18
|
+
* pattern) — describe never imports examples, so the base embed + IIFE stay lean.
|
|
19
|
+
*/
|
|
20
|
+
/** One runnable example, attached to its describe-key (node type / builder method
|
|
21
|
+
* / helper name). `code` is surfaced; `run` is the executed drift guard. */
|
|
22
|
+
interface ApiExample {
|
|
23
|
+
/** The describe-key this attaches to: a node type ('Rect'), builder method
|
|
24
|
+
* ('to'), or helper name ('splitText'). */
|
|
25
|
+
key: string;
|
|
26
|
+
/** The copy-pasteable snippet `describe({ examples: true })` surfaces. */
|
|
27
|
+
code: string;
|
|
28
|
+
/** Executes the SAME call against the real API — the doctest harness asserts it
|
|
29
|
+
* never throws, so the surfaced `code` can't reference a drifted API. */
|
|
30
|
+
run: () => void;
|
|
31
|
+
}
|
|
32
|
+
declare const EXAMPLES: readonly ApiExample[];
|
|
33
|
+
/** Group the corpus by describe-key → the surfaced code snippets. */
|
|
34
|
+
declare function examplesByKey(): {
|
|
35
|
+
readonly [key: string]: readonly string[];
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { ApiExample, EXAMPLES, examplesByKey };
|
package/dist/examples.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { a as Path, i as ImageNode, o as Rect, r as Group, s as Text, t as Circle } from "./nodes.js";
|
|
2
|
+
import { registerExamples } from "./describe.js";
|
|
3
|
+
import { a as evaluate, i as createScene } from "./scene.js";
|
|
4
|
+
import { Grid } from "./grid.js";
|
|
5
|
+
import { Stack } from "./layoutCtors.js";
|
|
6
|
+
import { splitText } from "./type.js";
|
|
7
|
+
import { r as motionPath } from "./motionPath.js";
|
|
8
|
+
import { pathFromSvg } from "./path.js";
|
|
9
|
+
import { key, timeline, track } from "@glissade/core";
|
|
10
|
+
//#region src/examples.ts
|
|
11
|
+
/**
|
|
12
|
+
* `@glissade/scene/examples` — the runnable example corpus (§0.24 onboarding,
|
|
13
|
+
* card `8jQ9rNqStGDL`). The single highest agent-onboarding cost is STALE
|
|
14
|
+
* examples: prose that drifts from the runtime, which a cold agent can't
|
|
15
|
+
* glance-test. The fix is to make canonical examples EXECUTABLE and surfaced from
|
|
16
|
+
* the same place the doctest harness runs them, so they can't go stale.
|
|
17
|
+
*
|
|
18
|
+
* Each {@link ApiExample} carries a copy-pasteable `code` string (what
|
|
19
|
+
* `describe({ examples: true })` surfaces) AND an executable `run` thunk against
|
|
20
|
+
* the REAL API (what `examples.test.ts` asserts never throws). The two sit
|
|
21
|
+
* adjacent and are kept in lockstep; the `run` guard is what fails CI the moment
|
|
22
|
+
* an example's API drifts (a renamed export, a changed shape).
|
|
23
|
+
*
|
|
24
|
+
* Tree-shaking: this module is OFF the base scene index (its own subpath entry),
|
|
25
|
+
* so a scene that never imports it pays zero bytes. Importing it REGISTERS the
|
|
26
|
+
* corpus with `describe()` via {@link registerExamples} (the value-type-registry
|
|
27
|
+
* pattern) — describe never imports examples, so the base embed + IIFE stay lean.
|
|
28
|
+
*/
|
|
29
|
+
/** A throwaway scene+evaluate so a node example proves it renders end-to-end, not
|
|
30
|
+
* just constructs (the estimating measurer suffices — no backend needed). */
|
|
31
|
+
function renders(...children) {
|
|
32
|
+
evaluate(createScene({
|
|
33
|
+
size: {
|
|
34
|
+
w: 320,
|
|
35
|
+
h: 200
|
|
36
|
+
},
|
|
37
|
+
children
|
|
38
|
+
}), timeline(() => {}), 0);
|
|
39
|
+
}
|
|
40
|
+
const EXAMPLES = [
|
|
41
|
+
{
|
|
42
|
+
key: "Rect",
|
|
43
|
+
code: "import { Rect } from '@glissade/scene';\nnew Rect({ position: [160, 100], width: 200, height: 100, fill: '#3b82f6', cornerRadius: 12 });",
|
|
44
|
+
run: () => renders(new Rect({
|
|
45
|
+
position: [160, 100],
|
|
46
|
+
width: 200,
|
|
47
|
+
height: 100,
|
|
48
|
+
fill: "#3b82f6",
|
|
49
|
+
cornerRadius: 12
|
|
50
|
+
}))
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: "Circle",
|
|
54
|
+
code: "import { Circle } from '@glissade/scene';\nnew Circle({ position: [160, 100], radius: 48, fill: '#ef4444' });",
|
|
55
|
+
run: () => renders(new Circle({
|
|
56
|
+
position: [160, 100],
|
|
57
|
+
radius: 48,
|
|
58
|
+
fill: "#ef4444"
|
|
59
|
+
}))
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: "Text",
|
|
63
|
+
code: "import { Text } from '@glissade/scene';\n// position anchors at the baseline-left by default; set `anchor` to share a corner with a shape\nnew Text({ position: [40, 60], text: 'Hello', fontSize: 32, fill: '#111827' });",
|
|
64
|
+
run: () => renders(new Text({
|
|
65
|
+
position: [40, 60],
|
|
66
|
+
text: "Hello",
|
|
67
|
+
fontSize: 32,
|
|
68
|
+
fill: "#111827"
|
|
69
|
+
}))
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "Path",
|
|
73
|
+
code: "import { Path } from '@glissade/scene';\nimport { pathFromSvg } from '@glissade/scene/path';\n// Path.data wants a PathValue — parse an SVG `d` string with pathFromSvg (NOT a raw string)\nnew Path({ data: pathFromSvg('M0 0 L100 0 L50 80 Z'), fill: '#10b981' });",
|
|
74
|
+
run: () => renders(new Path({
|
|
75
|
+
data: pathFromSvg("M0 0 L100 0 L50 80 Z"),
|
|
76
|
+
fill: "#10b981"
|
|
77
|
+
}))
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: "Group",
|
|
81
|
+
code: "import { Group, Rect, Text } from '@glissade/scene';\n// a Group nests its children as one unit (and renders as a wrapper <div> on the DOM tier)\nnew Group({ id: 'card', children: [\n new Rect({ anchor: 'top-left', position: [0, 0], width: 240, height: 96, fill: '#0f172a', cornerRadius: 16 }),\n new Text({ anchor: 'top-left', position: [16, 16], text: 'Title', fontSize: 24, fill: '#f8fafc' }),\n] });",
|
|
82
|
+
run: () => renders(new Group({
|
|
83
|
+
id: "card",
|
|
84
|
+
children: [new Rect({
|
|
85
|
+
anchor: "top-left",
|
|
86
|
+
position: [0, 0],
|
|
87
|
+
width: 240,
|
|
88
|
+
height: 96,
|
|
89
|
+
fill: "#0f172a",
|
|
90
|
+
cornerRadius: 16
|
|
91
|
+
}), new Text({
|
|
92
|
+
anchor: "top-left",
|
|
93
|
+
position: [16, 16],
|
|
94
|
+
text: "Title",
|
|
95
|
+
fontSize: 24,
|
|
96
|
+
fill: "#f8fafc"
|
|
97
|
+
})]
|
|
98
|
+
}))
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
key: "Image",
|
|
102
|
+
code: "import { Image } from '@glissade/scene';\n// `assetId` names a media entry declared on the Timeline: timeline({ assets: { hero: { kind: 'image', url } } })\nnew Image({ assetId: 'hero', position: [160, 100], width: 200, height: 120 });",
|
|
103
|
+
run: () => renders(new ImageNode({
|
|
104
|
+
assetId: "hero",
|
|
105
|
+
position: [160, 100],
|
|
106
|
+
width: 200,
|
|
107
|
+
height: 120
|
|
108
|
+
}))
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
key: "to",
|
|
112
|
+
code: "import { timeline } from '@glissade/core';\n// `from` anchors the start; the per-target cursor advances by `duration`\ntimeline((tl) => tl.to('card/position', [200, 100], { duration: 1, from: [0, 0] }));",
|
|
113
|
+
run: () => void timeline((tl) => tl.to("card/position", [200, 100], {
|
|
114
|
+
duration: 1,
|
|
115
|
+
from: [0, 0]
|
|
116
|
+
}))
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
key: "fromTo",
|
|
120
|
+
code: "import { timeline } from '@glissade/core';\ntimeline((tl) => tl.fromTo('card/opacity', 0, 1, { duration: 0.5 }));",
|
|
121
|
+
run: () => void timeline((tl) => tl.fromTo("card/opacity", 0, 1, { duration: .5 }))
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
key: "stagger",
|
|
125
|
+
code: "import { timeline } from '@glissade/core';\n// one tween per target, cascaded by `each`; `anchor` picks where the cascade ranks from\ntimeline((tl) => tl.stagger(['a/opacity', 'b/opacity', 'c/opacity'], { to: 1, from: 0, duration: 0.4 }, { each: 0.1 }));",
|
|
126
|
+
run: () => void timeline((tl) => tl.stagger([
|
|
127
|
+
"a/opacity",
|
|
128
|
+
"b/opacity",
|
|
129
|
+
"c/opacity"
|
|
130
|
+
], {
|
|
131
|
+
to: 1,
|
|
132
|
+
from: 0,
|
|
133
|
+
duration: .4
|
|
134
|
+
}, { each: .1 }))
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
key: "set",
|
|
138
|
+
code: "import { timeline } from '@glissade/core';\n// a hold key — the value snaps at the resolved position\ntimeline((tl) => tl.set('card/fill', '#ef4444', { at: 0.5 }));",
|
|
139
|
+
run: () => void timeline((tl) => tl.set("card/fill", "#ef4444", { at: .5 }))
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: "tracks",
|
|
143
|
+
code: "import { timeline, track, key } from '@glissade/core';\n// attach raw keyframe tracks (the value type is the 2nd arg)\ntimeline((tl) => tl.tracks([track('card/x', 'number', [key(0, 0), key(1, 100)])]));",
|
|
144
|
+
run: () => void timeline((tl) => tl.tracks([track("card/x", "number", [key(0, 0), key(1, 100)])]))
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
key: "splitText",
|
|
148
|
+
code: "import { splitText } from '@glissade/scene/type';\n// the source needs an `id` — parts bind tracks against `<id>/<i>`. sp.targets('opacity') gives the reveal-recipe targets\nconst sp = splitText({ id: 'title', text: 'Hello', fontSize: 40 }, { by: 'grapheme' });",
|
|
149
|
+
run: () => void splitText({
|
|
150
|
+
id: "title",
|
|
151
|
+
text: "Hello",
|
|
152
|
+
fontSize: 40
|
|
153
|
+
}, { by: "grapheme" })
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
key: "measureWrappedText",
|
|
157
|
+
code: "import { createScene } from '@glissade/scene';\n// size a bubble/card to wrapped text WITHOUT a Text node (the FontSpec field is `size`, not `fontSize`)\nconst scene = createScene({ size: { w: 400, h: 200 }, children: [] });\nconst { width, lines, height } = scene.measureWrappedText('a long string that wraps across the box', { family: 'sans-serif', size: 24 }, 280);",
|
|
158
|
+
run: () => {
|
|
159
|
+
createScene({
|
|
160
|
+
size: {
|
|
161
|
+
w: 400,
|
|
162
|
+
h: 200
|
|
163
|
+
},
|
|
164
|
+
children: []
|
|
165
|
+
}).measureWrappedText("a long string that wraps across the box", {
|
|
166
|
+
family: "sans-serif",
|
|
167
|
+
size: 24
|
|
168
|
+
}, 280);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
key: "Grid",
|
|
173
|
+
code: "import { Grid } from '@glissade/scene/grid';\n// build-time fan-out into a column grid (no Yoga) — children move to cell centers.\n// fr columns (`columns: 3`) need a `width` to resolve against; `cellHeight` is the row pitch\nGrid({ columns: 3, width: 360, gap: 16, cellHeight: 80, children: [new Rect({ width: 80, height: 60 }), new Rect({ width: 80, height: 60 })] });",
|
|
174
|
+
run: () => void Grid({
|
|
175
|
+
columns: 3,
|
|
176
|
+
width: 360,
|
|
177
|
+
gap: 16,
|
|
178
|
+
cellHeight: 80,
|
|
179
|
+
children: [new Rect({
|
|
180
|
+
width: 80,
|
|
181
|
+
height: 60
|
|
182
|
+
}), new Rect({
|
|
183
|
+
width: 80,
|
|
184
|
+
height: 60
|
|
185
|
+
})]
|
|
186
|
+
})
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
key: "Stack",
|
|
190
|
+
code: "import { Stack } from '@glissade/scene/layout';\nimport { loadYogaLayoutEngine } from '@glissade/scene/layout';\n// flexbox via Yoga — load the engine ONCE before evaluating any layout scene:\n// await loadYogaLayoutEngine();\nStack({ direction: 'row', gap: 16, children: [new Rect({ width: 80, height: 80 }), new Rect({ width: 80, height: 80 })] });",
|
|
191
|
+
run: () => void Stack({
|
|
192
|
+
direction: "row",
|
|
193
|
+
gap: 16,
|
|
194
|
+
children: [new Rect({
|
|
195
|
+
width: 80,
|
|
196
|
+
height: 80
|
|
197
|
+
}), new Rect({
|
|
198
|
+
width: 80,
|
|
199
|
+
height: 80
|
|
200
|
+
})]
|
|
201
|
+
})
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
key: "motionPath",
|
|
205
|
+
code: "import { motionPath } from '@glissade/scene/motion';\nimport { pathFromSvg } from '@glissade/scene/path';\nconst mp = motionPath(pathFromSvg('M0 0 C50 0 50 100 100 100'));\nconst pointHalfway = mp.atProgress(0.5); // { x, y }",
|
|
206
|
+
run: () => void motionPath(pathFromSvg("M0 0 C50 0 50 100 100 100")).atProgress(.5)
|
|
207
|
+
}
|
|
208
|
+
];
|
|
209
|
+
/** Group the corpus by describe-key → the surfaced code snippets. */
|
|
210
|
+
function examplesByKey() {
|
|
211
|
+
const byKey = {};
|
|
212
|
+
for (const ex of EXAMPLES) (byKey[ex.key] ??= []).push(ex.code);
|
|
213
|
+
return byKey;
|
|
214
|
+
}
|
|
215
|
+
registerExamples(examplesByKey());
|
|
216
|
+
//#endregion
|
|
217
|
+
export { EXAMPLES, examplesByKey };
|
package/dist/motion.js
CHANGED
|
@@ -1,171 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { signal } from "@glissade/core";
|
|
3
|
-
//#region src/motionPath.ts
|
|
4
|
-
/**
|
|
5
|
-
* Motion along a path: sample a point (and tangent) at an arc-length position on
|
|
6
|
-
* a PathValue, and drive a node along it over time. The Path node draws/morphs
|
|
7
|
-
* geometry; this is the companion that makes another node — a cursor, a dot, an
|
|
8
|
-
* arrow — *travel* that geometry.
|
|
9
|
-
*
|
|
10
|
-
* Sampling is arc-length parameterized (constant speed), so progress 0→1 moves
|
|
11
|
-
* evenly instead of bunching at the control points. Pure and deterministic: the
|
|
12
|
-
* table is built once from a static PathValue and `atProgress` is a pure
|
|
13
|
-
* function of progress, so evaluate() stays pure and goldens are byte-stable.
|
|
14
|
-
*/
|
|
15
|
-
const clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
|
|
16
|
-
function cubicPoint(p0, p1, p2, p3, t) {
|
|
17
|
-
const mt = 1 - t;
|
|
18
|
-
const a = mt * mt * mt;
|
|
19
|
-
const b = 3 * mt * mt * t;
|
|
20
|
-
const c = 3 * mt * t * t;
|
|
21
|
-
const d = t * t * t;
|
|
22
|
-
return [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]];
|
|
23
|
-
}
|
|
24
|
-
/** PathValue contours → cubic segments, same v/in/out math as Path.pathSegs. */
|
|
25
|
-
function toCubics(path) {
|
|
26
|
-
const out = [];
|
|
27
|
-
for (const ct of path) {
|
|
28
|
-
const n = ct.v.length;
|
|
29
|
-
for (let i = 0; i < n - 1; i++) out.push([
|
|
30
|
-
ct.v[i],
|
|
31
|
-
[ct.v[i][0] + ct.out[i][0], ct.v[i][1] + ct.out[i][1]],
|
|
32
|
-
[ct.v[i + 1][0] + ct.in[i + 1][0], ct.v[i + 1][1] + ct.in[i + 1][1]],
|
|
33
|
-
ct.v[i + 1]
|
|
34
|
-
]);
|
|
35
|
-
if (ct.closed && n > 1) out.push([
|
|
36
|
-
ct.v[n - 1],
|
|
37
|
-
[ct.v[n - 1][0] + ct.out[n - 1][0], ct.v[n - 1][1] + ct.out[n - 1][1]],
|
|
38
|
-
[ct.v[0][0] + ct.in[0][0], ct.v[0][1] + ct.in[0][1]],
|
|
39
|
-
ct.v[0]
|
|
40
|
-
]);
|
|
41
|
-
}
|
|
42
|
-
return out;
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Build a reusable arc-length sampler. Densely samples each cubic into a
|
|
46
|
-
* cumulative-length polyline (samplesPerSegment, default 32) so `at`/`tangent`
|
|
47
|
-
* are simple span lerps — smooth enough for motion, no per-call bezier solve.
|
|
48
|
-
*/
|
|
49
|
-
function motionPath(path, opts = {}) {
|
|
50
|
-
const steps = Math.max(1, Math.floor(opts.samplesPerSegment ?? 32));
|
|
51
|
-
const cubics = toCubics(path);
|
|
52
|
-
const pts = [];
|
|
53
|
-
const cum = [];
|
|
54
|
-
if (cubics.length > 0) {
|
|
55
|
-
let prev = cubicPoint(...cubics[0], 0);
|
|
56
|
-
pts.push(prev);
|
|
57
|
-
cum.push(0);
|
|
58
|
-
let acc = 0;
|
|
59
|
-
for (const cub of cubics) for (let k = 1; k <= steps; k++) {
|
|
60
|
-
const p = cubicPoint(...cub, k / steps);
|
|
61
|
-
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
|
|
62
|
-
pts.push(p);
|
|
63
|
-
cum.push(acc);
|
|
64
|
-
prev = p;
|
|
65
|
-
}
|
|
66
|
-
} else {
|
|
67
|
-
const first = path[0]?.v[0];
|
|
68
|
-
pts.push(first ? [first[0], first[1]] : [0, 0]);
|
|
69
|
-
cum.push(0);
|
|
70
|
-
}
|
|
71
|
-
const total = cum[cum.length - 1];
|
|
72
|
-
const locate = (s) => {
|
|
73
|
-
if (total <= 0 || s <= 0) return {
|
|
74
|
-
i: 0,
|
|
75
|
-
f: 0
|
|
76
|
-
};
|
|
77
|
-
if (s >= total) return {
|
|
78
|
-
i: pts.length - 2,
|
|
79
|
-
f: 1
|
|
80
|
-
};
|
|
81
|
-
let i = 0;
|
|
82
|
-
while (i < cum.length - 1 && cum[i + 1] < s) i++;
|
|
83
|
-
const span = cum[i + 1] - cum[i];
|
|
84
|
-
return {
|
|
85
|
-
i,
|
|
86
|
-
f: span > 0 ? (s - cum[i]) / span : 0
|
|
87
|
-
};
|
|
88
|
-
};
|
|
89
|
-
const at = (s) => {
|
|
90
|
-
if (pts.length === 1) return [pts[0][0], pts[0][1]];
|
|
91
|
-
const { i, f } = locate(s);
|
|
92
|
-
const a = pts[i];
|
|
93
|
-
const b = pts[i + 1];
|
|
94
|
-
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
|
|
95
|
-
};
|
|
96
|
-
const tangentAt = (s) => {
|
|
97
|
-
if (pts.length === 1) return [1, 0];
|
|
98
|
-
const { i } = locate(s);
|
|
99
|
-
const a = pts[i];
|
|
100
|
-
const b = pts[i + 1];
|
|
101
|
-
const dx = b[0] - a[0];
|
|
102
|
-
const dy = b[1] - a[1];
|
|
103
|
-
const len = Math.hypot(dx, dy);
|
|
104
|
-
return len > 0 ? [dx / len, dy / len] : [1, 0];
|
|
105
|
-
};
|
|
106
|
-
return {
|
|
107
|
-
length: total,
|
|
108
|
-
at,
|
|
109
|
-
tangentAt,
|
|
110
|
-
atProgress: (u) => at(clamp01(u) * total),
|
|
111
|
-
tangentAtProgress: (u) => tangentAt(clamp01(u) * total)
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
/** Total arc length of a path. */
|
|
115
|
-
function pathLength(path) {
|
|
116
|
-
return motionPath(path).length;
|
|
117
|
-
}
|
|
118
|
-
/** Point at arc-length s along a path (clamped to [0, length]). */
|
|
119
|
-
function pointAtLength(path, s) {
|
|
120
|
-
return motionPath(path).at(s);
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* A companion node that drives `target` along `path` as `progress` animates.
|
|
124
|
-
* Owns the target's `position` (and `rotation` when `orient`) via pull-based
|
|
125
|
-
* binding, so there's no eval-order side effect. Add it to the scene (its
|
|
126
|
-
* `progress` is the animatable target); it draws nothing itself.
|
|
127
|
-
*/
|
|
128
|
-
var FollowPath = class extends Node {
|
|
129
|
-
target;
|
|
130
|
-
progress;
|
|
131
|
-
constructor(props) {
|
|
132
|
-
super(props);
|
|
133
|
-
this.target = props.target;
|
|
134
|
-
this.progress = signal(1);
|
|
135
|
-
if (typeof props.progress === "function") this.progress.bindSource(props.progress);
|
|
136
|
-
else if (props.progress !== void 0) this.progress.set(props.progress);
|
|
137
|
-
this.registerTarget("progress", this.progress, "number");
|
|
138
|
-
const sOpts = props.samplesPerSegment !== void 0 ? { samplesPerSegment: props.samplesPerSegment } : {};
|
|
139
|
-
const getPath = props.path instanceof Path ? () => props.path.data() : () => props.path;
|
|
140
|
-
let cachedPath = getPath();
|
|
141
|
-
let cachedSampler = motionPath(cachedPath, sOpts);
|
|
142
|
-
const sampler = () => {
|
|
143
|
-
const pv = getPath();
|
|
144
|
-
if (pv !== cachedPath) {
|
|
145
|
-
cachedPath = pv;
|
|
146
|
-
cachedSampler = motionPath(pv, sOpts);
|
|
147
|
-
}
|
|
148
|
-
return cachedSampler;
|
|
149
|
-
};
|
|
150
|
-
props.target.position.bindSource(() => sampler().atProgress(this.progress()));
|
|
151
|
-
if (props.orient) {
|
|
152
|
-
const offset = props.orientOffset ?? 0;
|
|
153
|
-
props.target.rotation.bindSource(() => {
|
|
154
|
-
const t = sampler().tangentAtProgress(this.progress());
|
|
155
|
-
return Math.atan2(t[1], t[0]) * 180 / Math.PI + offset;
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
draw() {}
|
|
160
|
-
};
|
|
161
|
-
/** `children: [route, cursor, followPath(cursor, route, { orient: true })]` — cursor traces the route.
|
|
162
|
-
* Pass the Path *node* to follow it as it morphs; pass a PathValue for a fixed route. */
|
|
163
|
-
function followPath(target, path, props = {}) {
|
|
164
|
-
return new FollowPath({
|
|
165
|
-
...props,
|
|
166
|
-
target,
|
|
167
|
-
path
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
//#endregion
|
|
1
|
+
import { a as pointAtLength, i as pathLength, n as followPath, r as motionPath, t as FollowPath } from "./motionPath.js";
|
|
171
2
|
export { FollowPath, followPath, motionPath, pathLength, pointAtLength };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { a as Path, p as Node } from "./nodes.js";
|
|
2
|
+
import { signal } from "@glissade/core";
|
|
3
|
+
//#region src/motionPath.ts
|
|
4
|
+
/**
|
|
5
|
+
* Motion along a path: sample a point (and tangent) at an arc-length position on
|
|
6
|
+
* a PathValue, and drive a node along it over time. The Path node draws/morphs
|
|
7
|
+
* geometry; this is the companion that makes another node — a cursor, a dot, an
|
|
8
|
+
* arrow — *travel* that geometry.
|
|
9
|
+
*
|
|
10
|
+
* Sampling is arc-length parameterized (constant speed), so progress 0→1 moves
|
|
11
|
+
* evenly instead of bunching at the control points. Pure and deterministic: the
|
|
12
|
+
* table is built once from a static PathValue and `atProgress` is a pure
|
|
13
|
+
* function of progress, so evaluate() stays pure and goldens are byte-stable.
|
|
14
|
+
*/
|
|
15
|
+
const clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
|
|
16
|
+
function cubicPoint(p0, p1, p2, p3, t) {
|
|
17
|
+
const mt = 1 - t;
|
|
18
|
+
const a = mt * mt * mt;
|
|
19
|
+
const b = 3 * mt * mt * t;
|
|
20
|
+
const c = 3 * mt * t * t;
|
|
21
|
+
const d = t * t * t;
|
|
22
|
+
return [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]];
|
|
23
|
+
}
|
|
24
|
+
/** PathValue contours → cubic segments, same v/in/out math as Path.pathSegs. */
|
|
25
|
+
function toCubics(path) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const ct of path) {
|
|
28
|
+
const n = ct.v.length;
|
|
29
|
+
for (let i = 0; i < n - 1; i++) out.push([
|
|
30
|
+
ct.v[i],
|
|
31
|
+
[ct.v[i][0] + ct.out[i][0], ct.v[i][1] + ct.out[i][1]],
|
|
32
|
+
[ct.v[i + 1][0] + ct.in[i + 1][0], ct.v[i + 1][1] + ct.in[i + 1][1]],
|
|
33
|
+
ct.v[i + 1]
|
|
34
|
+
]);
|
|
35
|
+
if (ct.closed && n > 1) out.push([
|
|
36
|
+
ct.v[n - 1],
|
|
37
|
+
[ct.v[n - 1][0] + ct.out[n - 1][0], ct.v[n - 1][1] + ct.out[n - 1][1]],
|
|
38
|
+
[ct.v[0][0] + ct.in[0][0], ct.v[0][1] + ct.in[0][1]],
|
|
39
|
+
ct.v[0]
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build a reusable arc-length sampler. Densely samples each cubic into a
|
|
46
|
+
* cumulative-length polyline (samplesPerSegment, default 32) so `at`/`tangent`
|
|
47
|
+
* are simple span lerps — smooth enough for motion, no per-call bezier solve.
|
|
48
|
+
*/
|
|
49
|
+
function motionPath(path, opts = {}) {
|
|
50
|
+
const steps = Math.max(1, Math.floor(opts.samplesPerSegment ?? 32));
|
|
51
|
+
const cubics = toCubics(path);
|
|
52
|
+
const pts = [];
|
|
53
|
+
const cum = [];
|
|
54
|
+
if (cubics.length > 0) {
|
|
55
|
+
let prev = cubicPoint(...cubics[0], 0);
|
|
56
|
+
pts.push(prev);
|
|
57
|
+
cum.push(0);
|
|
58
|
+
let acc = 0;
|
|
59
|
+
for (const cub of cubics) for (let k = 1; k <= steps; k++) {
|
|
60
|
+
const p = cubicPoint(...cub, k / steps);
|
|
61
|
+
acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
|
|
62
|
+
pts.push(p);
|
|
63
|
+
cum.push(acc);
|
|
64
|
+
prev = p;
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
const first = path[0]?.v[0];
|
|
68
|
+
pts.push(first ? [first[0], first[1]] : [0, 0]);
|
|
69
|
+
cum.push(0);
|
|
70
|
+
}
|
|
71
|
+
const total = cum[cum.length - 1];
|
|
72
|
+
const locate = (s) => {
|
|
73
|
+
if (total <= 0 || s <= 0) return {
|
|
74
|
+
i: 0,
|
|
75
|
+
f: 0
|
|
76
|
+
};
|
|
77
|
+
if (s >= total) return {
|
|
78
|
+
i: pts.length - 2,
|
|
79
|
+
f: 1
|
|
80
|
+
};
|
|
81
|
+
let i = 0;
|
|
82
|
+
while (i < cum.length - 1 && cum[i + 1] < s) i++;
|
|
83
|
+
const span = cum[i + 1] - cum[i];
|
|
84
|
+
return {
|
|
85
|
+
i,
|
|
86
|
+
f: span > 0 ? (s - cum[i]) / span : 0
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
const at = (s) => {
|
|
90
|
+
if (pts.length === 1) return [pts[0][0], pts[0][1]];
|
|
91
|
+
const { i, f } = locate(s);
|
|
92
|
+
const a = pts[i];
|
|
93
|
+
const b = pts[i + 1];
|
|
94
|
+
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
|
|
95
|
+
};
|
|
96
|
+
const tangentAt = (s) => {
|
|
97
|
+
if (pts.length === 1) return [1, 0];
|
|
98
|
+
const { i } = locate(s);
|
|
99
|
+
const a = pts[i];
|
|
100
|
+
const b = pts[i + 1];
|
|
101
|
+
const dx = b[0] - a[0];
|
|
102
|
+
const dy = b[1] - a[1];
|
|
103
|
+
const len = Math.hypot(dx, dy);
|
|
104
|
+
return len > 0 ? [dx / len, dy / len] : [1, 0];
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
length: total,
|
|
108
|
+
at,
|
|
109
|
+
tangentAt,
|
|
110
|
+
atProgress: (u) => at(clamp01(u) * total),
|
|
111
|
+
tangentAtProgress: (u) => tangentAt(clamp01(u) * total)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** Total arc length of a path. */
|
|
115
|
+
function pathLength(path) {
|
|
116
|
+
return motionPath(path).length;
|
|
117
|
+
}
|
|
118
|
+
/** Point at arc-length s along a path (clamped to [0, length]). */
|
|
119
|
+
function pointAtLength(path, s) {
|
|
120
|
+
return motionPath(path).at(s);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A companion node that drives `target` along `path` as `progress` animates.
|
|
124
|
+
* Owns the target's `position` (and `rotation` when `orient`) via pull-based
|
|
125
|
+
* binding, so there's no eval-order side effect. Add it to the scene (its
|
|
126
|
+
* `progress` is the animatable target); it draws nothing itself.
|
|
127
|
+
*/
|
|
128
|
+
var FollowPath = class extends Node {
|
|
129
|
+
target;
|
|
130
|
+
progress;
|
|
131
|
+
constructor(props) {
|
|
132
|
+
super(props);
|
|
133
|
+
this.target = props.target;
|
|
134
|
+
this.progress = signal(1);
|
|
135
|
+
if (typeof props.progress === "function") this.progress.bindSource(props.progress);
|
|
136
|
+
else if (props.progress !== void 0) this.progress.set(props.progress);
|
|
137
|
+
this.registerTarget("progress", this.progress, "number");
|
|
138
|
+
const sOpts = props.samplesPerSegment !== void 0 ? { samplesPerSegment: props.samplesPerSegment } : {};
|
|
139
|
+
const getPath = props.path instanceof Path ? () => props.path.data() : () => props.path;
|
|
140
|
+
let cachedPath = getPath();
|
|
141
|
+
let cachedSampler = motionPath(cachedPath, sOpts);
|
|
142
|
+
const sampler = () => {
|
|
143
|
+
const pv = getPath();
|
|
144
|
+
if (pv !== cachedPath) {
|
|
145
|
+
cachedPath = pv;
|
|
146
|
+
cachedSampler = motionPath(pv, sOpts);
|
|
147
|
+
}
|
|
148
|
+
return cachedSampler;
|
|
149
|
+
};
|
|
150
|
+
props.target.position.bindSource(() => sampler().atProgress(this.progress()));
|
|
151
|
+
if (props.orient) {
|
|
152
|
+
const offset = props.orientOffset ?? 0;
|
|
153
|
+
props.target.rotation.bindSource(() => {
|
|
154
|
+
const t = sampler().tangentAtProgress(this.progress());
|
|
155
|
+
return Math.atan2(t[1], t[0]) * 180 / Math.PI + offset;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
draw() {}
|
|
160
|
+
};
|
|
161
|
+
/** `children: [route, cursor, followPath(cursor, route, { orient: true })]` — cursor traces the route.
|
|
162
|
+
* Pass the Path *node* to follow it as it morphs; pass a PathValue for a fixed route. */
|
|
163
|
+
function followPath(target, path, props = {}) {
|
|
164
|
+
return new FollowPath({
|
|
165
|
+
...props,
|
|
166
|
+
target,
|
|
167
|
+
path
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
171
|
+
export { pointAtLength as a, pathLength as i, followPath as n, motionPath as r, FollowPath as t };
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/scene",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0-pre.0",
|
|
4
4
|
"description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.19"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
|
-
"sideEffects":
|
|
10
|
+
"sideEffects": [
|
|
11
|
+
"./dist/examples.js"
|
|
12
|
+
],
|
|
11
13
|
"exports": {
|
|
12
14
|
".": {
|
|
13
15
|
"types": "./dist/index.d.ts",
|
|
@@ -52,6 +54,10 @@
|
|
|
52
54
|
"./identity": {
|
|
53
55
|
"types": "./dist/identity.d.ts",
|
|
54
56
|
"default": "./dist/identity.js"
|
|
57
|
+
},
|
|
58
|
+
"./examples": {
|
|
59
|
+
"types": "./dist/examples.d.ts",
|
|
60
|
+
"default": "./dist/examples.js"
|
|
55
61
|
}
|
|
56
62
|
},
|
|
57
63
|
"files": [
|
|
@@ -59,7 +65,7 @@
|
|
|
59
65
|
],
|
|
60
66
|
"dependencies": {
|
|
61
67
|
"yoga-layout": "^3.2.1",
|
|
62
|
-
"@glissade/core": "0.
|
|
68
|
+
"@glissade/core": "0.24.0-pre.0"
|
|
63
69
|
},
|
|
64
70
|
"repository": {
|
|
65
71
|
"type": "git",
|