@weasel-js/parallax 0.0.0-stage → 1.9.4
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 +78 -2
- package/dist/index.d.ts +140 -0
- package/dist/index.js +100 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 orochi235
|
|
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,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weasel-js/parallax
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Multi-plane parallax: render the same layers through *derived* views so
|
|
4
|
+
background planes move less than the camera and foreground planes move more.
|
|
5
|
+
|
|
6
|
+
Part of [weasel](https://github.com/orochi235/weasel), a domain-agnostic 2D
|
|
7
|
+
scene-graph canvas kit for React. See the
|
|
8
|
+
[API reference](https://orochi235.github.io/weasel/api/).
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @weasel-js/parallax
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The math is pure and is exported by `@weasel-js/core` (`deriveParallaxView`,
|
|
15
|
+
`planeMap`, and the rest), since a scene layer's own `parallax` uses it. This
|
|
16
|
+
package holds the layer wrapper (`createParallaxLayer`), the live plane
|
|
17
|
+
(`createParallaxPlane`), and a layer that repeats its sources across a period
|
|
18
|
+
(`createTiledLayer`, `tiledProject`).
|
|
19
|
+
|
|
20
|
+
## `deriveParallaxView(outer, opts)`
|
|
21
|
+
|
|
22
|
+
Pure. Takes the camera `View` and per-plane factors, returns the plane's inner
|
|
23
|
+
`View`. No state, no side effects — trivially testable, and reusable anywhere
|
|
24
|
+
you need "a view that tracks the camera at a fraction."
|
|
25
|
+
|
|
26
|
+
| Option | Meaning |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `pan` | How much the plane translates with the camera. `1` = normal, `0` = locked to `anchor`, `>1` = leads the camera. |
|
|
29
|
+
| `zoom` | How much it scales with camera zoom. `1` = normal, `0` = fixed at identity scale. |
|
|
30
|
+
| `anchor` | The camera position at which every plane lines up. Defaults to the origin. |
|
|
31
|
+
|
|
32
|
+
Both `pan` and `zoom` take a scalar or `{x, y}`, so you can parallax one axis
|
|
33
|
+
only — common for side-scrolling backdrops.
|
|
34
|
+
|
|
35
|
+
**Identity holds:** `pan: 1, zoom: 1` returns a view equal to `outer`. That's
|
|
36
|
+
the invariant to preserve if you touch the math; a plane at defaults must be
|
|
37
|
+
pixel-identical to no parallax at all.
|
|
38
|
+
|
|
39
|
+
## `planeMap(outer, opts)`
|
|
40
|
+
|
|
41
|
+
The same plane as an axis-aligned map from the camera's world into the plane's
|
|
42
|
+
(`toPlane` / `fromPlane`, `rectToPlane` / `rectFromPlane`): the two points it
|
|
43
|
+
pairs sit under the same pixel. It is how a pointer, a marquee or a selection
|
|
44
|
+
box crosses between the camera and a plane.
|
|
45
|
+
|
|
46
|
+
## Two kinds of plane
|
|
47
|
+
|
|
48
|
+
| | Paint | Scene nodes |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| Declared by | `createParallaxLayer({ source, parallax })` | `parallax` on a scene layer (`systemLayers`, `addLayer`, `setLayerParallax`) |
|
|
51
|
+
| Draws | any render layers | the layer's nodes, through `<SceneCanvas>`'s scene walk |
|
|
52
|
+
| Clickable | no | yes — picking, marquee, lasso and selection chrome go through the plane |
|
|
53
|
+
| Changed live by | a `ParallaxPlane` (`createParallaxPlane`) passed as `parallax` | `scene.setLayerParallax`, inside `scene.untracked` when animating |
|
|
54
|
+
|
|
55
|
+
Editing a node on a plane — move, resize, rotate, clone, insert, anchor edits,
|
|
56
|
+
snapping — goes through the plane too (`inPlane`,
|
|
57
|
+
`interactions/actions/planeInput.ts`), including a selection that spans planes
|
|
58
|
+
and a drop into a container on another plane.
|
|
59
|
+
|
|
60
|
+
## Animating a plane
|
|
61
|
+
|
|
62
|
+
A plane's opts are values an animator drives: tween a number and write it in
|
|
63
|
+
`onTick`.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const plane = createParallaxPlane({ pan: 0.4 });
|
|
67
|
+
animator.tween({ from: -1200, to: 0, ms: 1400, onTick: (x) => plane.set({ anchor: { x, y: 0 } }) });
|
|
68
|
+
// A scene layer: untracked, so no frame becomes an undo step.
|
|
69
|
+
animator.tween({ from: 0, to: 0.4, ms: 800, onTick: (pan) =>
|
|
70
|
+
scene.untracked(() => scene.setLayerParallax('hills', { pan })) });
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Picking `anchor`
|
|
74
|
+
|
|
75
|
+
`anchor` is a camera position — the view's `x`/`y`, its top-left in world
|
|
76
|
+
units — and every plane lines up exactly when the camera sits there. Put it
|
|
77
|
+
where the composition should read as registered, usually the camera's starting
|
|
78
|
+
position. Tweening it is how the demo's intro throws the planes apart and
|
|
79
|
+
settles them.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { RedrawSource, ParallaxOpts, RenderLayer, View, Dims } from '@weasel-js/core';
|
|
2
|
+
|
|
3
|
+
/** Parallax opts that can change after a layer is built. `get` is read on
|
|
4
|
+
* every draw, and a notification repaints whatever draws through it. */
|
|
5
|
+
interface ParallaxSource extends RedrawSource {
|
|
6
|
+
get(): ParallaxOpts;
|
|
7
|
+
}
|
|
8
|
+
/** A {@link ParallaxSource} you write to — what an animator's `onTick` drives
|
|
9
|
+
* to tween a plane's `pan`, `zoom` or `anchor`. */
|
|
10
|
+
interface ParallaxPlane extends ParallaxSource {
|
|
11
|
+
/** Merge `patch` over the current opts and notify. */
|
|
12
|
+
set(patch: Partial<ParallaxOpts>): void;
|
|
13
|
+
}
|
|
14
|
+
/** A {@link ParallaxPlane} starting at `initial`. Pass it as a parallax
|
|
15
|
+
* layer's `parallax` and write to it to move the plane. */
|
|
16
|
+
declare function createParallaxPlane(initial: ParallaxOpts): ParallaxPlane;
|
|
17
|
+
|
|
18
|
+
/** Options for `createParallaxLayer`. */
|
|
19
|
+
interface CreateParallaxLayerOpts<TData> {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
/** Layers re-rendered through the derived inner view. */
|
|
23
|
+
source: RenderLayer<TData>[];
|
|
24
|
+
/** How the plane tracks the camera. Pass a `ParallaxSource` (such as
|
|
25
|
+
* `createParallaxPlane`) to change it after the layer is built — an
|
|
26
|
+
* animator tweening `pan` or `zoom` writes the plane, not the layer. */
|
|
27
|
+
parallax: ParallaxOpts | ParallaxSource;
|
|
28
|
+
/** Where the camera view comes from. Defaults to the view the Canvas hands
|
|
29
|
+
* the layer — the `view` prop. A consumer running a 60 Hz camera through
|
|
30
|
+
* refs pins that prop to identity, and would otherwise get identity back
|
|
31
|
+
* for every `pan` value and a backdrop that never moves. */
|
|
32
|
+
getOuterView?: () => View;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Wrap source RenderLayers in a parallax plane. The plane's inner view is
|
|
36
|
+
* derived from the camera view per {@link deriveParallaxView}; each source
|
|
37
|
+
* layer is drawn through it by `drawOneLayer`, so a `space: 'world'` source
|
|
38
|
+
* gets the inner view's transform and a `space: 'screen'` one does not. The
|
|
39
|
+
* plane itself is emitted as `space: 'screen'` — its children already carry
|
|
40
|
+
* whatever transform they need, and the outer Canvas must add none.
|
|
41
|
+
*
|
|
42
|
+
* What this draws is paint: pointer events target the camera's view. Content
|
|
43
|
+
* that should be clickable belongs on a scene layer carrying `parallax`
|
|
44
|
+
* instead, which `<SceneCanvas>` renders and picks through the same view.
|
|
45
|
+
*
|
|
46
|
+
* **Screen-space source layers don't compose meaningfully** — they ignore
|
|
47
|
+
* the derived view by definition. Same constraint as `createViewportLayer`.
|
|
48
|
+
*/
|
|
49
|
+
declare function createParallaxLayer<TData>(opts: CreateParallaxLayerOpts<TData>): RenderLayer<TData>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Which copies of a periodic lattice a visible range touches.
|
|
53
|
+
*
|
|
54
|
+
* A tiled layer's content is authored once, in the cell `[0, period)`, and the
|
|
55
|
+
* lattice repeats it at every multiple of `period`. This answers the only
|
|
56
|
+
* question drawing it needs: the integer copies `k` whose cell overlaps
|
|
57
|
+
* `[visStart, visEnd]`. Copy `k` draws the authored content shifted by
|
|
58
|
+
* `k * period`.
|
|
59
|
+
*
|
|
60
|
+
* `bleed` is how far the content reaches outside its own cell, in world
|
|
61
|
+
* units — the printing term, and the same idea. Content that overhangs by
|
|
62
|
+
* more than it declares pops in at the edge of the view instead of scrolling
|
|
63
|
+
* in.
|
|
64
|
+
*/
|
|
65
|
+
interface TileRange {
|
|
66
|
+
/** First copy index, inclusive. */
|
|
67
|
+
from: number;
|
|
68
|
+
/** Last copy index, inclusive. `to < from` means nothing is visible. */
|
|
69
|
+
to: number;
|
|
70
|
+
}
|
|
71
|
+
/** The copies of a lattice with cell size `period` that `[visStart, visEnd]`
|
|
72
|
+
* touches, widened by `bleed`. A non-positive or non-finite `period` answers
|
|
73
|
+
* copy 0 alone. */
|
|
74
|
+
declare function tiledProject(visStart: number, visEnd: number, period: number, bleed?: number): TileRange;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How far apart the copies sit, in world units. A bare number wraps the x
|
|
78
|
+
* axis only — the side-scrolling backdrop this is usually reached for.
|
|
79
|
+
* `{ x, y }` wraps both; naming one key wraps that axis alone.
|
|
80
|
+
*
|
|
81
|
+
* The period names the axes, so there is no separate `axis` option: two ways
|
|
82
|
+
* to say which axes wrap is one way for them to disagree.
|
|
83
|
+
*/
|
|
84
|
+
type TilePeriod = number | {
|
|
85
|
+
x?: number;
|
|
86
|
+
y?: number;
|
|
87
|
+
};
|
|
88
|
+
/** How far the content reaches outside its own cell, in world units. */
|
|
89
|
+
type TileBleed = number | {
|
|
90
|
+
x?: number;
|
|
91
|
+
y?: number;
|
|
92
|
+
};
|
|
93
|
+
/** Options for {@link createTiledLayer}. */
|
|
94
|
+
interface CreateTiledLayerOpts<TData> {
|
|
95
|
+
id: string;
|
|
96
|
+
label: string;
|
|
97
|
+
/** Layers redrawn once per visible copy. */
|
|
98
|
+
source: RenderLayer<TData>[];
|
|
99
|
+
/**
|
|
100
|
+
* The lattice. A function is read per draw, so the period can follow the
|
|
101
|
+
* camera or the canvas — "one screen wide" is a period, not a constant.
|
|
102
|
+
*/
|
|
103
|
+
period: TilePeriod | ((view: View, dims: Dims) => TilePeriod);
|
|
104
|
+
/**
|
|
105
|
+
* How far the source's content reaches outside the cell `[0, period)`, in
|
|
106
|
+
* world units. Content that overhangs by more than it declares pops in at
|
|
107
|
+
* the edge of the view instead of scrolling in. Default 0.
|
|
108
|
+
*/
|
|
109
|
+
bleed?: TileBleed;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Wrap source RenderLayers in a periodic lattice: the content is authored
|
|
113
|
+
* once, in the cell `[0, period)`, and every copy of it the view can see is
|
|
114
|
+
* drawn. Panning in either direction keeps producing copies, so the content
|
|
115
|
+
* loops seamlessly with no state and no wrap-around bookkeeping.
|
|
116
|
+
*
|
|
117
|
+
* Each copy draws the source through a view shifted by `-k * period`, so a
|
|
118
|
+
* source that culls against the view it is handed culls per copy and never
|
|
119
|
+
* learns it is being tiled. The copy's commands are then translated by
|
|
120
|
+
* `+k * period` in world space.
|
|
121
|
+
*
|
|
122
|
+
* The lattice is per layer, not per shape. Content on two different periods
|
|
123
|
+
* is two tiled layers stacked, which keeps each source ignorant of the other
|
|
124
|
+
* — a per-shape period would move the lattice into the source and make the
|
|
125
|
+
* wrapper unable to cull.
|
|
126
|
+
*
|
|
127
|
+
* The plane is `space: 'world'`, which is what lets it be a `source` of
|
|
128
|
+
* `createParallaxLayer`: the parallax plane hands it the derived inner view,
|
|
129
|
+
* the lattice resolves against that, and the copies ride the plane.
|
|
130
|
+
*
|
|
131
|
+
* **Zoomed out until a draw would take more than a few thousand copies, the
|
|
132
|
+
* lattice draws none.** A cell is then a pixel or two across.
|
|
133
|
+
*
|
|
134
|
+
* **A `space: 'screen'` source is drawn once, untiled** — it ignores the view
|
|
135
|
+
* by definition, so every copy would land on top of the last. Same constraint
|
|
136
|
+
* `createParallaxLayer` carries.
|
|
137
|
+
*/
|
|
138
|
+
declare function createTiledLayer<TData>(opts: CreateTiledLayerOpts<TData>): RenderLayer<TData>;
|
|
139
|
+
|
|
140
|
+
export { type CreateParallaxLayerOpts, type CreateTiledLayerOpts, type ParallaxPlane, type ParallaxSource, type TileBleed, type TilePeriod, type TileRange, createParallaxLayer, createParallaxPlane, createTiledLayer, tiledProject };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { subscribeToSources, deriveParallaxView, drawOneLayer } from '@weasel-js/core';
|
|
2
|
+
import { createEmitter } from '@weasel-js/registry';
|
|
3
|
+
|
|
4
|
+
// src/createParallaxLayer.ts
|
|
5
|
+
function createParallaxPlane(initial) {
|
|
6
|
+
let current = { ...initial };
|
|
7
|
+
const listeners = createEmitter();
|
|
8
|
+
return {
|
|
9
|
+
get: () => current,
|
|
10
|
+
set(patch) {
|
|
11
|
+
current = { ...current, ...patch };
|
|
12
|
+
listeners.emit();
|
|
13
|
+
},
|
|
14
|
+
subscribe: listeners.subscribe
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function isParallaxSource(p) {
|
|
18
|
+
return typeof p.get === "function";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/createParallaxLayer.ts
|
|
22
|
+
function createParallaxLayer(opts) {
|
|
23
|
+
const { id, label, source, parallax, getOuterView } = opts;
|
|
24
|
+
const read = isParallaxSource(parallax) ? () => parallax.get() : () => parallax;
|
|
25
|
+
const fromSources = subscribeToSources(source);
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
label,
|
|
29
|
+
space: "screen",
|
|
30
|
+
subscribe: isParallaxSource(parallax) ? (listener) => {
|
|
31
|
+
const offSources = fromSources(listener);
|
|
32
|
+
const offPlane = parallax.subscribe(listener);
|
|
33
|
+
return () => {
|
|
34
|
+
offSources();
|
|
35
|
+
offPlane();
|
|
36
|
+
};
|
|
37
|
+
} : fromSources,
|
|
38
|
+
draw: (data, outer, dims) => {
|
|
39
|
+
const inner = deriveParallaxView(getOuterView?.() ?? outer, read());
|
|
40
|
+
return source.flatMap((layer) => drawOneLayer(layer, data, inner, dims));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/tiledProject.ts
|
|
46
|
+
function tiledProject(visStart, visEnd, period, bleed = 0) {
|
|
47
|
+
if (!Number.isFinite(period) || period <= 0) return { from: 0, to: 0 };
|
|
48
|
+
return {
|
|
49
|
+
from: Math.floor((visStart - bleed) / period),
|
|
50
|
+
to: Math.floor((visEnd + bleed) / period)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
var MAX_COPIES = 4096;
|
|
54
|
+
function axes(v, fallback) {
|
|
55
|
+
if (v === void 0) return { x: fallback, y: fallback };
|
|
56
|
+
if (typeof v === "number") return { x: v, y: fallback };
|
|
57
|
+
return { x: v.x ?? fallback, y: v.y ?? fallback };
|
|
58
|
+
}
|
|
59
|
+
function translate(tx, ty) {
|
|
60
|
+
return new Float64Array([1, 0, 0, 0, 1, 0, tx, ty, 1]);
|
|
61
|
+
}
|
|
62
|
+
function createTiledLayer(opts) {
|
|
63
|
+
const { id, label, source, period, bleed } = opts;
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
label,
|
|
67
|
+
space: "world",
|
|
68
|
+
subscribe: subscribeToSources(source),
|
|
69
|
+
draw: (data, view, dims) => {
|
|
70
|
+
const p = axes(typeof period === "function" ? period(view, dims) : period, Infinity);
|
|
71
|
+
const b = axes(bleed, 0);
|
|
72
|
+
const spanX = dims.width / view.scale.x;
|
|
73
|
+
const spanY = dims.height / view.scale.y;
|
|
74
|
+
const kx = tiledProject(view.x, view.x + spanX, p.x, b.x);
|
|
75
|
+
const ky = tiledProject(view.y, view.y + spanY, p.y, b.y);
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const layer of source) {
|
|
78
|
+
if ((layer.space ?? "world") === "screen") out.push(...layer.draw(data, view, dims));
|
|
79
|
+
}
|
|
80
|
+
const tiles = source.filter((layer) => (layer.space ?? "world") !== "screen");
|
|
81
|
+
if (!((kx.to - kx.from + 1) * (ky.to - ky.from + 1) <= MAX_COPIES)) return out;
|
|
82
|
+
for (let i = kx.from; i <= kx.to; i++) {
|
|
83
|
+
for (let j = ky.from; j <= ky.to; j++) {
|
|
84
|
+
const tx = Number.isFinite(p.x) ? i * p.x : 0;
|
|
85
|
+
const ty = Number.isFinite(p.y) ? j * p.y : 0;
|
|
86
|
+
const shifted = { ...view, x: view.x - tx, y: view.y - ty };
|
|
87
|
+
const children = [];
|
|
88
|
+
for (const layer of tiles) children.push(...layer.draw(data, shifted, dims));
|
|
89
|
+
if (children.length === 0) continue;
|
|
90
|
+
out.push(tx === 0 && ty === 0 ? { kind: "group", children } : { kind: "group", transform: translate(tx, ty), children });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { createParallaxLayer, createParallaxPlane, createTiledLayer, tiledProject };
|
|
99
|
+
//# sourceMappingURL=index.js.map
|
|
100
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/createParallaxPlane.ts","../src/createParallaxLayer.ts","../src/tiledProject.ts","../src/createTiledLayer.ts"],"names":["subscribeToSources"],"mappings":"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","file":"index.js","sourcesContent":["import { createEmitter } from '@weasel-js/registry';\nimport type { RedrawSource } from '@weasel-js/core';\nimport type { ParallaxOpts } from '@weasel-js/core';\n\n/** Parallax opts that can change after a layer is built. `get` is read on\n * every draw, and a notification repaints whatever draws through it. */\nexport interface ParallaxSource extends RedrawSource {\n get(): ParallaxOpts;\n}\n\n/** A {@link ParallaxSource} you write to — what an animator's `onTick` drives\n * to tween a plane's `pan`, `zoom` or `anchor`. */\nexport interface ParallaxPlane extends ParallaxSource {\n /** Merge `patch` over the current opts and notify. */\n set(patch: Partial<ParallaxOpts>): void;\n}\n\n/** A {@link ParallaxPlane} starting at `initial`. Pass it as a parallax\n * layer's `parallax` and write to it to move the plane. */\nexport function createParallaxPlane(initial: ParallaxOpts): ParallaxPlane {\n let current: ParallaxOpts = { ...initial };\n const listeners = createEmitter();\n return {\n get: () => current,\n set(patch) {\n current = { ...current, ...patch };\n listeners.emit();\n },\n subscribe: listeners.subscribe,\n };\n}\n\n/** Whether `p` is a live source rather than a fixed set of opts. */\nexport function isParallaxSource(p: ParallaxOpts | ParallaxSource): p is ParallaxSource {\n return typeof (p as ParallaxSource).get === 'function';\n}\n","import { drawOneLayer, subscribeToSources, type RenderLayer } from '@weasel-js/core';\nimport type { View } from '@weasel-js/core';\nimport { deriveParallaxView, type ParallaxOpts } from '@weasel-js/core';\nimport { isParallaxSource, type ParallaxSource } from './createParallaxPlane';\n\n/** Options for `createParallaxLayer`. */\nexport interface CreateParallaxLayerOpts<TData> {\n id: string;\n label: string;\n /** Layers re-rendered through the derived inner view. */\n source: RenderLayer<TData>[];\n /** How the plane tracks the camera. Pass a `ParallaxSource` (such as\n * `createParallaxPlane`) to change it after the layer is built — an\n * animator tweening `pan` or `zoom` writes the plane, not the layer. */\n parallax: ParallaxOpts | ParallaxSource;\n /** Where the camera view comes from. Defaults to the view the Canvas hands\n * the layer — the `view` prop. A consumer running a 60 Hz camera through\n * refs pins that prop to identity, and would otherwise get identity back\n * for every `pan` value and a backdrop that never moves. */\n getOuterView?: () => View;\n}\n\n/**\n * Wrap source RenderLayers in a parallax plane. The plane's inner view is\n * derived from the camera view per {@link deriveParallaxView}; each source\n * layer is drawn through it by `drawOneLayer`, so a `space: 'world'` source\n * gets the inner view's transform and a `space: 'screen'` one does not. The\n * plane itself is emitted as `space: 'screen'` — its children already carry\n * whatever transform they need, and the outer Canvas must add none.\n *\n * What this draws is paint: pointer events target the camera's view. Content\n * that should be clickable belongs on a scene layer carrying `parallax`\n * instead, which `<SceneCanvas>` renders and picks through the same view.\n *\n * **Screen-space source layers don't compose meaningfully** — they ignore\n * the derived view by definition. Same constraint as `createViewportLayer`.\n */\nexport function createParallaxLayer<TData>(\n opts: CreateParallaxLayerOpts<TData>,\n): RenderLayer<TData> {\n const { id, label, source, parallax, getOuterView } = opts;\n const read = isParallaxSource(parallax) ? () => parallax.get() : () => parallax;\n const fromSources = subscribeToSources(source);\n return {\n id,\n label,\n space: 'screen',\n subscribe: isParallaxSource(parallax)\n ? (listener) => {\n const offSources = fromSources(listener);\n const offPlane = parallax.subscribe(listener);\n return () => { offSources(); offPlane(); };\n }\n : fromSources,\n draw: (data, outer, dims) => {\n const inner = deriveParallaxView(getOuterView?.() ?? outer, read());\n return source.flatMap((layer) => drawOneLayer(layer, data, inner, dims));\n },\n };\n}\n","/**\n * Which copies of a periodic lattice a visible range touches.\n *\n * A tiled layer's content is authored once, in the cell `[0, period)`, and the\n * lattice repeats it at every multiple of `period`. This answers the only\n * question drawing it needs: the integer copies `k` whose cell overlaps\n * `[visStart, visEnd]`. Copy `k` draws the authored content shifted by\n * `k * period`.\n *\n * `bleed` is how far the content reaches outside its own cell, in world\n * units — the printing term, and the same idea. Content that overhangs by\n * more than it declares pops in at the edge of the view instead of scrolling\n * in.\n */\nexport interface TileRange {\n /** First copy index, inclusive. */\n from: number;\n /** Last copy index, inclusive. `to < from` means nothing is visible. */\n to: number;\n}\n\n/** The copies of a lattice with cell size `period` that `[visStart, visEnd]`\n * touches, widened by `bleed`. A non-positive or non-finite `period` answers\n * copy 0 alone. */\nexport function tiledProject(\n visStart: number,\n visEnd: number,\n period: number,\n bleed = 0,\n): TileRange {\n if (!Number.isFinite(period) || period <= 0) return { from: 0, to: 0 };\n return {\n from: Math.floor((visStart - bleed) / period),\n to: Math.floor((visEnd + bleed) / period),\n };\n}\n","import type { DrawCommand } from '@weasel-js/core';\nimport { subscribeToSources, type Dims, type RenderLayer } from '@weasel-js/core';\nimport type { View } from '@weasel-js/core';\nimport { tiledProject } from './tiledProject';\n\n/**\n * How far apart the copies sit, in world units. A bare number wraps the x\n * axis only — the side-scrolling backdrop this is usually reached for.\n * `{ x, y }` wraps both; naming one key wraps that axis alone.\n *\n * The period names the axes, so there is no separate `axis` option: two ways\n * to say which axes wrap is one way for them to disagree.\n */\nexport type TilePeriod = number | { x?: number; y?: number };\n\n/** How far the content reaches outside its own cell, in world units. */\nexport type TileBleed = number | { x?: number; y?: number };\n\n/** Options for {@link createTiledLayer}. */\nexport interface CreateTiledLayerOpts<TData> {\n id: string;\n label: string;\n /** Layers redrawn once per visible copy. */\n source: RenderLayer<TData>[];\n /**\n * The lattice. A function is read per draw, so the period can follow the\n * camera or the canvas — \"one screen wide\" is a period, not a constant.\n */\n period: TilePeriod | ((view: View, dims: Dims) => TilePeriod);\n /**\n * How far the source's content reaches outside the cell `[0, period)`, in\n * world units. Content that overhangs by more than it declares pops in at\n * the edge of the view instead of scrolling in. Default 0.\n */\n bleed?: TileBleed;\n}\n\ninterface Axes { x: number; y: number }\n\n/** Copies one draw will make. Past this the view is zoomed so far out that a\n * cell covers a pixel or two, and each copy still costs a full source draw. */\nconst MAX_COPIES = 4096;\n\nfunction axes(v: TilePeriod | TileBleed | undefined, fallback: number): Axes {\n if (v === undefined) return { x: fallback, y: fallback };\n if (typeof v === 'number') return { x: v, y: fallback };\n return { x: v.x ?? fallback, y: v.y ?? fallback };\n}\n\n/** Column-major, the layout `Mat3` documents. */\nfunction translate(tx: number, ty: number): Float64Array {\n return new Float64Array([1, 0, 0, 0, 1, 0, tx, ty, 1]);\n}\n\n/**\n * Wrap source RenderLayers in a periodic lattice: the content is authored\n * once, in the cell `[0, period)`, and every copy of it the view can see is\n * drawn. Panning in either direction keeps producing copies, so the content\n * loops seamlessly with no state and no wrap-around bookkeeping.\n *\n * Each copy draws the source through a view shifted by `-k * period`, so a\n * source that culls against the view it is handed culls per copy and never\n * learns it is being tiled. The copy's commands are then translated by\n * `+k * period` in world space.\n *\n * The lattice is per layer, not per shape. Content on two different periods\n * is two tiled layers stacked, which keeps each source ignorant of the other\n * — a per-shape period would move the lattice into the source and make the\n * wrapper unable to cull.\n *\n * The plane is `space: 'world'`, which is what lets it be a `source` of\n * `createParallaxLayer`: the parallax plane hands it the derived inner view,\n * the lattice resolves against that, and the copies ride the plane.\n *\n * **Zoomed out until a draw would take more than a few thousand copies, the\n * lattice draws none.** A cell is then a pixel or two across.\n *\n * **A `space: 'screen'` source is drawn once, untiled** — it ignores the view\n * by definition, so every copy would land on top of the last. Same constraint\n * `createParallaxLayer` carries.\n */\nexport function createTiledLayer<TData>(\n opts: CreateTiledLayerOpts<TData>,\n): RenderLayer<TData> {\n const { id, label, source, period, bleed } = opts;\n return {\n id,\n label,\n space: 'world',\n subscribe: subscribeToSources(source),\n draw: (data, view, dims) => {\n const p = axes(typeof period === 'function' ? period(view, dims) : period, Infinity);\n const b = axes(bleed, 0);\n const spanX = dims.width / view.scale.x;\n const spanY = dims.height / view.scale.y;\n const kx = tiledProject(view.x, view.x + spanX, p.x, b.x);\n const ky = tiledProject(view.y, view.y + spanY, p.y, b.y);\n\n const out: DrawCommand[] = [];\n for (const layer of source) {\n if ((layer.space ?? 'world') === 'screen') out.push(...layer.draw(data, view, dims));\n }\n const tiles = source.filter((layer) => (layer.space ?? 'world') !== 'screen');\n // Written so a non-finite span, which a zero view scale gives, fails it too.\n if (!((kx.to - kx.from + 1) * (ky.to - ky.from + 1) <= MAX_COPIES)) return out;\n\n for (let i = kx.from; i <= kx.to; i++) {\n for (let j = ky.from; j <= ky.to; j++) {\n const tx = Number.isFinite(p.x) ? i * p.x : 0;\n const ty = Number.isFinite(p.y) ? j * p.y : 0;\n const shifted: View = { ...view, x: view.x - tx, y: view.y - ty };\n const children: DrawCommand[] = [];\n for (const layer of tiles) children.push(...layer.draw(data, shifted, dims));\n if (children.length === 0) continue;\n out.push(tx === 0 && ty === 0\n ? { kind: 'group', children }\n : { kind: 'group', transform: translate(tx, ty), children });\n }\n }\n return out;\n },\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/parallax",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.9.4",
|
|
4
|
+
"description": "Parallax planes and tiled surfaces for weasel: layers that draw other layers through a derived view, and a live plane a camera drives. No React.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"author": "orochi235",
|
|
19
|
+
"homepage": "https://orochi235.github.io/weasel/",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/orochi235/weasel.git",
|
|
23
|
+
"directory": "packages/parallax"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/orochi235/weasel/issues"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@weasel-js/registry": "1.9.4"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@weasel-js/core": "1.9.4"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"provenance": true
|
|
48
|
+
}
|
|
49
|
+
}
|