animot-presenter 0.2.9 → 0.5.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/dist/AnimotPresenter.svelte +1654 -1626
- package/dist/FlowMarkers.svelte +111 -0
- package/dist/FlowMarkers.svelte.d.ts +10 -0
- package/dist/cdn/animot-presenter.css +1 -1
- package/dist/cdn/animot-presenter.esm.js +5003 -4674
- package/dist/cdn/animot-presenter.min.js +9 -9
- package/dist/renderers/IconRenderer.svelte +19 -1
- package/dist/styles/presenter.css +53 -0
- package/dist/types.d.ts +24 -1
- package/dist/utils/arrow-clip-draw.d.ts +28 -0
- package/dist/utils/arrow-clip-draw.js +177 -0
- package/dist/utils/arrow-path.d.ts +15 -0
- package/dist/utils/arrow-path.js +73 -0
- package/dist/utils/trace-svg-paths.d.ts +28 -0
- package/dist/utils/trace-svg-paths.js +247 -0
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { IconElement } from '../types';
|
|
3
|
+
import { traceSvgPaths } from '../utils/trace-svg-paths';
|
|
4
|
+
|
|
3
5
|
interface Props { element: IconElement; }
|
|
4
6
|
let { element }: Props = $props();
|
|
5
7
|
|
|
@@ -8,9 +10,25 @@
|
|
|
8
10
|
const stroke = element.fillMode === 'stroke' || element.fillMode === 'both' ? element.color : 'none';
|
|
9
11
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${fill}" stroke="${stroke}" stroke-width="${element.strokeWidth}" stroke-linecap="round" stroke-linejoin="round">${element.svgContent}</svg>`;
|
|
10
12
|
});
|
|
13
|
+
|
|
14
|
+
const animMode = $derived(element.animation?.mode ?? 'none');
|
|
15
|
+
const animDur = $derived(element.animation?.duration ?? 800);
|
|
16
|
+
const animLoop = $derived(element.animation?.loop ?? false);
|
|
17
|
+
const animReverse = $derived(element.animation?.direction === 'reverse');
|
|
18
|
+
const animKey = $derived(`${element.iconName}-${element.iconLibrary}-${animMode}-${animDur}-${animLoop}-${animReverse}`);
|
|
11
19
|
</script>
|
|
12
20
|
|
|
13
|
-
<div
|
|
21
|
+
<div
|
|
22
|
+
class="icon-element"
|
|
23
|
+
use:traceSvgPaths={{
|
|
24
|
+
enabled: animMode !== 'none',
|
|
25
|
+
mode: animMode as 'none' | 'draw' | 'undraw' | 'draw-undraw' | 'flow',
|
|
26
|
+
duration: animDur,
|
|
27
|
+
loop: animLoop,
|
|
28
|
+
reverse: animReverse,
|
|
29
|
+
key: animKey
|
|
30
|
+
}}
|
|
31
|
+
>{@html svgMarkup()}</div>
|
|
14
32
|
|
|
15
33
|
<style>
|
|
16
34
|
.icon-element { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
|
@@ -46,3 +46,56 @@
|
|
|
46
46
|
83% { translate: calc(-0.2 * var(--float-amp, 10px)) calc(0.8 * var(--float-amp, 10px)); }
|
|
47
47
|
100% { translate: 0 0; }
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/* SVG path-trace animations (Icon + Svg elements) */
|
|
51
|
+
.animot-svg-element.icon-anim-draw path,
|
|
52
|
+
.animot-svg-element.icon-anim-draw circle,
|
|
53
|
+
.animot-svg-element.icon-anim-draw rect,
|
|
54
|
+
.animot-svg-element.icon-anim-draw line,
|
|
55
|
+
.animot-svg-element.icon-anim-draw polyline,
|
|
56
|
+
.animot-svg-element.icon-anim-draw polygon,
|
|
57
|
+
.animot-svg-element.icon-anim-draw ellipse {
|
|
58
|
+
stroke-dashoffset: var(--path-len, 1000);
|
|
59
|
+
animation: animot-svg-draw var(--icon-anim-duration, 800ms) ease-out forwards;
|
|
60
|
+
}
|
|
61
|
+
.animot-svg-element.icon-anim-undraw path,
|
|
62
|
+
.animot-svg-element.icon-anim-undraw circle,
|
|
63
|
+
.animot-svg-element.icon-anim-undraw rect,
|
|
64
|
+
.animot-svg-element.icon-anim-undraw line,
|
|
65
|
+
.animot-svg-element.icon-anim-undraw polyline,
|
|
66
|
+
.animot-svg-element.icon-anim-undraw polygon,
|
|
67
|
+
.animot-svg-element.icon-anim-undraw ellipse {
|
|
68
|
+
stroke-dashoffset: 0;
|
|
69
|
+
animation: animot-svg-undraw var(--icon-anim-duration, 800ms) ease-out forwards;
|
|
70
|
+
}
|
|
71
|
+
.animot-svg-element.icon-anim-draw-undraw path,
|
|
72
|
+
.animot-svg-element.icon-anim-draw-undraw circle,
|
|
73
|
+
.animot-svg-element.icon-anim-draw-undraw rect,
|
|
74
|
+
.animot-svg-element.icon-anim-draw-undraw line,
|
|
75
|
+
.animot-svg-element.icon-anim-draw-undraw polyline,
|
|
76
|
+
.animot-svg-element.icon-anim-draw-undraw polygon,
|
|
77
|
+
.animot-svg-element.icon-anim-draw-undraw ellipse {
|
|
78
|
+
stroke-dashoffset: var(--path-len, 1000);
|
|
79
|
+
animation: animot-svg-draw-undraw var(--icon-anim-duration, 800ms) ease-out forwards;
|
|
80
|
+
}
|
|
81
|
+
.animot-svg-element.icon-anim-loop path,
|
|
82
|
+
.animot-svg-element.icon-anim-loop circle,
|
|
83
|
+
.animot-svg-element.icon-anim-loop rect,
|
|
84
|
+
.animot-svg-element.icon-anim-loop line,
|
|
85
|
+
.animot-svg-element.icon-anim-loop polyline,
|
|
86
|
+
.animot-svg-element.icon-anim-loop polygon,
|
|
87
|
+
.animot-svg-element.icon-anim-loop ellipse { animation-iteration-count: infinite !important; }
|
|
88
|
+
.animot-svg-element.icon-anim-reverse path,
|
|
89
|
+
.animot-svg-element.icon-anim-reverse circle,
|
|
90
|
+
.animot-svg-element.icon-anim-reverse rect,
|
|
91
|
+
.animot-svg-element.icon-anim-reverse line,
|
|
92
|
+
.animot-svg-element.icon-anim-reverse polyline,
|
|
93
|
+
.animot-svg-element.icon-anim-reverse polygon,
|
|
94
|
+
.animot-svg-element.icon-anim-reverse ellipse { animation-direction: reverse !important; }
|
|
95
|
+
@keyframes animot-svg-draw { to { stroke-dashoffset: 0; } }
|
|
96
|
+
@keyframes animot-svg-undraw { from { stroke-dashoffset: 0; } to { stroke-dashoffset: var(--path-len, 1000); } }
|
|
97
|
+
@keyframes animot-svg-draw-undraw {
|
|
98
|
+
0% { stroke-dashoffset: var(--path-len, 1000); }
|
|
99
|
+
50% { stroke-dashoffset: 0; }
|
|
100
|
+
100% { stroke-dashoffset: var(--path-len, 1000); }
|
|
101
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -145,10 +145,30 @@ export interface TextElement extends BaseElement {
|
|
|
145
145
|
backgroundPositionY?: number;
|
|
146
146
|
backgroundScale?: number;
|
|
147
147
|
}
|
|
148
|
-
export type ArrowAnimationMode = 'none' | 'grow' | 'draw' | 'undraw' | 'draw-undraw';
|
|
148
|
+
export type ArrowAnimationMode = 'none' | 'grow' | 'draw' | 'undraw' | 'draw-undraw' | 'flow';
|
|
149
149
|
export interface ArrowAnimationConfig {
|
|
150
150
|
mode: ArrowAnimationMode;
|
|
151
151
|
duration: number;
|
|
152
|
+
loop?: boolean;
|
|
153
|
+
direction?: 'forward' | 'reverse';
|
|
154
|
+
}
|
|
155
|
+
export type SvgPathAnimationMode = 'none' | 'draw' | 'undraw' | 'draw-undraw' | 'flow';
|
|
156
|
+
export interface SvgPathAnimationConfig {
|
|
157
|
+
mode: SvgPathAnimationMode;
|
|
158
|
+
duration: number;
|
|
159
|
+
loop?: boolean;
|
|
160
|
+
direction?: 'forward' | 'reverse';
|
|
161
|
+
}
|
|
162
|
+
export interface FlowMarkerConfig {
|
|
163
|
+
enabled: boolean;
|
|
164
|
+
count: number;
|
|
165
|
+
size: number;
|
|
166
|
+
color: string;
|
|
167
|
+
speedMs: number;
|
|
168
|
+
direction: 'forward' | 'reverse' | 'bidirectional';
|
|
169
|
+
pattern: 'loop' | 'once' | 'bounce';
|
|
170
|
+
shape: 'dot' | 'square' | 'pulse';
|
|
171
|
+
easing: 'linear' | 'ease-in-out';
|
|
152
172
|
}
|
|
153
173
|
export interface ArrowElement extends BaseElement {
|
|
154
174
|
type: 'arrow';
|
|
@@ -162,6 +182,7 @@ export interface ArrowElement extends BaseElement {
|
|
|
162
182
|
showHead: boolean;
|
|
163
183
|
opacity: number;
|
|
164
184
|
animation?: ArrowAnimationConfig;
|
|
185
|
+
flowMarkers?: FlowMarkerConfig;
|
|
165
186
|
}
|
|
166
187
|
export interface ImageElement extends BaseElement {
|
|
167
188
|
type: 'image';
|
|
@@ -248,6 +269,7 @@ export interface IconElement extends BaseElement {
|
|
|
248
269
|
color: string;
|
|
249
270
|
strokeWidth: number;
|
|
250
271
|
fillMode: 'stroke' | 'fill' | 'both';
|
|
272
|
+
animation?: SvgPathAnimationConfig;
|
|
251
273
|
}
|
|
252
274
|
export interface SvgElement extends BaseElement {
|
|
253
275
|
type: 'svg';
|
|
@@ -256,6 +278,7 @@ export interface SvgElement extends BaseElement {
|
|
|
256
278
|
opacity: number;
|
|
257
279
|
preserveAspectRatio: string;
|
|
258
280
|
viewBox?: string;
|
|
281
|
+
animation?: SvgPathAnimationConfig;
|
|
259
282
|
}
|
|
260
283
|
export interface MotionPathElement extends BaseElement {
|
|
261
284
|
type: 'motionPath';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte action for arrow animations. Two strategies depending on mode:
|
|
3
|
+
*
|
|
4
|
+
* • 'draw' / 'undraw' / 'draw-undraw' — clip-path inset on the host <svg>.
|
|
5
|
+
* Doesn't touch stroke-dasharray, so dashed/dotted patterns are preserved
|
|
6
|
+
* while the path is progressively revealed/hidden from one side.
|
|
7
|
+
*
|
|
8
|
+
* • 'flow' — marching ants. Continuously shifts stroke-dashoffset on the
|
|
9
|
+
* inner .arrow-path, so the dash pattern appears to flow along the path
|
|
10
|
+
* like a current. Forward/reverse controls flow direction. Inherently
|
|
11
|
+
* looping; the loop flag is ignored.
|
|
12
|
+
*/
|
|
13
|
+
export interface ArrowClipDrawParams {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
mode: 'draw' | 'undraw' | 'draw-undraw' | 'flow' | 'none' | string;
|
|
16
|
+
duration: number;
|
|
17
|
+
startX: number;
|
|
18
|
+
startY: number;
|
|
19
|
+
endX: number;
|
|
20
|
+
endY: number;
|
|
21
|
+
loop?: boolean;
|
|
22
|
+
reverse?: boolean;
|
|
23
|
+
key?: unknown;
|
|
24
|
+
}
|
|
25
|
+
export declare function arrowClipDraw(node: SVGSVGElement, params: ArrowClipDrawParams): {
|
|
26
|
+
update(p: ArrowClipDrawParams): void;
|
|
27
|
+
destroy(): void;
|
|
28
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte action for arrow animations. Two strategies depending on mode:
|
|
3
|
+
*
|
|
4
|
+
* • 'draw' / 'undraw' / 'draw-undraw' — clip-path inset on the host <svg>.
|
|
5
|
+
* Doesn't touch stroke-dasharray, so dashed/dotted patterns are preserved
|
|
6
|
+
* while the path is progressively revealed/hidden from one side.
|
|
7
|
+
*
|
|
8
|
+
* • 'flow' — marching ants. Continuously shifts stroke-dashoffset on the
|
|
9
|
+
* inner .arrow-path, so the dash pattern appears to flow along the path
|
|
10
|
+
* like a current. Forward/reverse controls flow direction. Inherently
|
|
11
|
+
* looping; the loop flag is ignored.
|
|
12
|
+
*/
|
|
13
|
+
export function arrowClipDraw(node, params) {
|
|
14
|
+
let raf = 0;
|
|
15
|
+
let pathEl = null;
|
|
16
|
+
let baseDash = '';
|
|
17
|
+
let baseOffset = '';
|
|
18
|
+
function clearArrowPath() {
|
|
19
|
+
if (!pathEl)
|
|
20
|
+
return;
|
|
21
|
+
pathEl.style.strokeDasharray = baseDash;
|
|
22
|
+
pathEl.style.strokeDashoffset = baseOffset;
|
|
23
|
+
}
|
|
24
|
+
function reset() {
|
|
25
|
+
if (raf)
|
|
26
|
+
cancelAnimationFrame(raf);
|
|
27
|
+
raf = 0;
|
|
28
|
+
node.style.clipPath = '';
|
|
29
|
+
clearArrowPath();
|
|
30
|
+
}
|
|
31
|
+
function run() {
|
|
32
|
+
reset();
|
|
33
|
+
if (!params.enabled || params.mode === 'none')
|
|
34
|
+
return;
|
|
35
|
+
// Capture the inner arrow path (used by 'flow' mode).
|
|
36
|
+
pathEl = node.querySelector('.arrow-path');
|
|
37
|
+
if (pathEl) {
|
|
38
|
+
baseDash = pathEl.style.strokeDasharray || '';
|
|
39
|
+
baseOffset = pathEl.style.strokeDashoffset || '';
|
|
40
|
+
}
|
|
41
|
+
// Pick the dominant axis so vertical arrows reveal top↔bottom and horizontal
|
|
42
|
+
// arrows reveal left↔right. clip-path inset(top right bottom left).
|
|
43
|
+
const dx = params.endX - params.startX;
|
|
44
|
+
const dy = params.endY - params.startY;
|
|
45
|
+
const horizontal = Math.abs(dx) >= Math.abs(dy);
|
|
46
|
+
const positive = horizontal ? dx >= 0 : dy >= 0;
|
|
47
|
+
const goesPositive = params.reverse ? !positive : positive;
|
|
48
|
+
// Build the clip-path string for a given progress (0 = fully hidden,
|
|
49
|
+
// 100 = fully visible) on the dominant axis.
|
|
50
|
+
function clip(insetPct) {
|
|
51
|
+
if (horizontal) {
|
|
52
|
+
return goesPositive
|
|
53
|
+
? `inset(0 ${insetPct}% 0 0)` // hide from right edge
|
|
54
|
+
: `inset(0 0 0 ${insetPct}%)`; // hide from left edge
|
|
55
|
+
}
|
|
56
|
+
return goesPositive
|
|
57
|
+
? `inset(0 0 ${insetPct}% 0)` // hide from bottom
|
|
58
|
+
: `inset(${insetPct}% 0 0 0)`; // hide from top
|
|
59
|
+
}
|
|
60
|
+
const clipFull = clip(100); // fully hidden (100% inset on hide side)
|
|
61
|
+
const clipNone = clip(0);
|
|
62
|
+
const dur = Math.max(50, params.duration);
|
|
63
|
+
const start = performance.now();
|
|
64
|
+
const m = params.mode;
|
|
65
|
+
// FLOW: marching ants — animate dashoffset continuously, keep base pattern.
|
|
66
|
+
if (m === 'flow') {
|
|
67
|
+
if (!pathEl)
|
|
68
|
+
return;
|
|
69
|
+
// If the arrow has no inline dasharray (i.e. it's solid), give it one
|
|
70
|
+
// so dashes are visible to flow. Otherwise keep the user's pattern.
|
|
71
|
+
const dashAttr = pathEl.getAttribute('stroke-dasharray');
|
|
72
|
+
if (!dashAttr || dashAttr === 'none') {
|
|
73
|
+
pathEl.style.strokeDasharray = '8 5';
|
|
74
|
+
}
|
|
75
|
+
// One pixel of dashoffset shift per ms feels like a steady current; use
|
|
76
|
+
// `duration` as the ms per cycle of 24px (one base dash repeat-ish).
|
|
77
|
+
const cycle = 24;
|
|
78
|
+
const dir = params.reverse ? 1 : -1;
|
|
79
|
+
function flowStep(now) {
|
|
80
|
+
const elapsed = now - start;
|
|
81
|
+
const offset = (dir * (elapsed / dur) * cycle) % (cycle * 1000); // huge mod just to keep it bounded
|
|
82
|
+
if (pathEl)
|
|
83
|
+
pathEl.style.strokeDashoffset = String(offset);
|
|
84
|
+
raf = requestAnimationFrame(flowStep);
|
|
85
|
+
}
|
|
86
|
+
raf = requestAnimationFrame(flowStep);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Initial clip-path state for draw modes.
|
|
90
|
+
if (m === 'draw' || m === 'draw-undraw') {
|
|
91
|
+
node.style.clipPath = clipFull;
|
|
92
|
+
}
|
|
93
|
+
else if (m === 'undraw') {
|
|
94
|
+
node.style.clipPath = clipNone;
|
|
95
|
+
}
|
|
96
|
+
function step(now) {
|
|
97
|
+
const elapsed = now - start;
|
|
98
|
+
if (m === 'draw') {
|
|
99
|
+
const t = Math.min(elapsed / dur, 1);
|
|
100
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
101
|
+
node.style.clipPath = clip(100 * (1 - eased));
|
|
102
|
+
if (t < 1)
|
|
103
|
+
raf = requestAnimationFrame(step);
|
|
104
|
+
else if (params.loop)
|
|
105
|
+
run();
|
|
106
|
+
else {
|
|
107
|
+
node.style.clipPath = clipNone;
|
|
108
|
+
raf = 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (m === 'undraw') {
|
|
112
|
+
const t = Math.min(elapsed / dur, 1);
|
|
113
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
114
|
+
node.style.clipPath = clip(100 * eased);
|
|
115
|
+
if (t < 1)
|
|
116
|
+
raf = requestAnimationFrame(step);
|
|
117
|
+
else if (params.loop)
|
|
118
|
+
run();
|
|
119
|
+
else {
|
|
120
|
+
node.style.clipPath = clipFull;
|
|
121
|
+
raf = 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else if (m === 'draw-undraw') {
|
|
125
|
+
const half = dur / 2;
|
|
126
|
+
if (elapsed < half) {
|
|
127
|
+
const t = Math.min(elapsed / half, 1);
|
|
128
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
129
|
+
node.style.clipPath = clip(100 * (1 - eased));
|
|
130
|
+
raf = requestAnimationFrame(step);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const t = Math.min((elapsed - half) / half, 1);
|
|
134
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
135
|
+
node.style.clipPath = clip(100 * eased);
|
|
136
|
+
if (t < 1)
|
|
137
|
+
raf = requestAnimationFrame(step);
|
|
138
|
+
else if (params.loop)
|
|
139
|
+
run();
|
|
140
|
+
else {
|
|
141
|
+
node.style.clipPath = clipFull;
|
|
142
|
+
raf = 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
raf = requestAnimationFrame(step);
|
|
148
|
+
}
|
|
149
|
+
// Defer to next tick so the inner <path class="arrow-path"> is already
|
|
150
|
+
// rendered when we query it.
|
|
151
|
+
queueMicrotask(run);
|
|
152
|
+
// Register so the video-export pipeline can restart this animation after
|
|
153
|
+
// activating the virtual clock (see initFirstSlideAnimations).
|
|
154
|
+
if (typeof window !== 'undefined') {
|
|
155
|
+
const reg = (window.__svgAnimRestart ||= []);
|
|
156
|
+
reg.push(run);
|
|
157
|
+
node.__svgAnimRestart = run;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
update(p) {
|
|
161
|
+
params = p;
|
|
162
|
+
queueMicrotask(run);
|
|
163
|
+
},
|
|
164
|
+
destroy() {
|
|
165
|
+
reset();
|
|
166
|
+
if (typeof window !== 'undefined') {
|
|
167
|
+
const reg = window.__svgAnimRestart;
|
|
168
|
+
const fn = node.__svgAnimRestart;
|
|
169
|
+
if (reg && fn) {
|
|
170
|
+
const idx = reg.indexOf(fn);
|
|
171
|
+
if (idx >= 0)
|
|
172
|
+
reg.splice(idx, 1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface PathPoint {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
angle: number;
|
|
5
|
+
}
|
|
6
|
+
interface Vec {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Sample a single point along an arrow's path at parameter t in [0, 1].
|
|
12
|
+
* Returns x, y and the tangent angle in radians (useful for orienting markers).
|
|
13
|
+
*/
|
|
14
|
+
export declare function arrowPointAt(start: Vec, end: Vec, control: Vec[], t: number): PathPoint;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Pure helpers to compute a point (and its tangent direction) along an arrow's
|
|
2
|
+
// path at parameter t in [0, 1]. Mirrors the path-construction rules used by
|
|
3
|
+
// Arrow.svelte (linear, quadratic, cubic, Catmull-Rom for 3+ control points).
|
|
4
|
+
//
|
|
5
|
+
// Used by FlowMarkers and any other consumer that needs to follow the curve.
|
|
6
|
+
function lerp(a, b, t) {
|
|
7
|
+
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
|
8
|
+
}
|
|
9
|
+
function quadratic(p0, p1, p2, t) {
|
|
10
|
+
const a = lerp(p0, p1, t);
|
|
11
|
+
const b = lerp(p1, p2, t);
|
|
12
|
+
return lerp(a, b, t);
|
|
13
|
+
}
|
|
14
|
+
function quadraticTangent(p0, p1, p2, t) {
|
|
15
|
+
// derivative of quadratic Bezier
|
|
16
|
+
return {
|
|
17
|
+
x: 2 * (1 - t) * (p1.x - p0.x) + 2 * t * (p2.x - p1.x),
|
|
18
|
+
y: 2 * (1 - t) * (p1.y - p0.y) + 2 * t * (p2.y - p1.y)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function cubic(p0, p1, p2, p3, t) {
|
|
22
|
+
const a = lerp(p0, p1, t);
|
|
23
|
+
const b = lerp(p1, p2, t);
|
|
24
|
+
const c = lerp(p2, p3, t);
|
|
25
|
+
const d = lerp(a, b, t);
|
|
26
|
+
const e = lerp(b, c, t);
|
|
27
|
+
return lerp(d, e, t);
|
|
28
|
+
}
|
|
29
|
+
function cubicTangent(p0, p1, p2, p3, t) {
|
|
30
|
+
const u = 1 - t;
|
|
31
|
+
return {
|
|
32
|
+
x: 3 * u * u * (p1.x - p0.x) + 6 * u * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x),
|
|
33
|
+
y: 3 * u * u * (p1.y - p0.y) + 6 * u * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Sample a single point along an arrow's path at parameter t in [0, 1].
|
|
38
|
+
* Returns x, y and the tangent angle in radians (useful for orienting markers).
|
|
39
|
+
*/
|
|
40
|
+
export function arrowPointAt(start, end, control, t) {
|
|
41
|
+
t = Math.max(0, Math.min(1, t));
|
|
42
|
+
if (control.length === 0) {
|
|
43
|
+
// straight line
|
|
44
|
+
const p = lerp(start, end, t);
|
|
45
|
+
return { x: p.x, y: p.y, angle: Math.atan2(end.y - start.y, end.x - start.x) };
|
|
46
|
+
}
|
|
47
|
+
if (control.length === 1) {
|
|
48
|
+
const p = quadratic(start, control[0], end, t);
|
|
49
|
+
const tan = quadraticTangent(start, control[0], end, t);
|
|
50
|
+
return { x: p.x, y: p.y, angle: Math.atan2(tan.y, tan.x) };
|
|
51
|
+
}
|
|
52
|
+
if (control.length === 2) {
|
|
53
|
+
const p = cubic(start, control[0], control[1], end, t);
|
|
54
|
+
const tan = cubicTangent(start, control[0], control[1], end, t);
|
|
55
|
+
return { x: p.x, y: p.y, angle: Math.atan2(tan.y, tan.x) };
|
|
56
|
+
}
|
|
57
|
+
// 3+ control points: Catmull-Rom approximated as cubic Bezier segments
|
|
58
|
+
// (matches Arrow.svelte's pathD rendering).
|
|
59
|
+
const pts = [start, ...control, end];
|
|
60
|
+
const segCount = pts.length - 1;
|
|
61
|
+
const segLen = 1 / segCount;
|
|
62
|
+
const segIndex = Math.min(segCount - 1, Math.floor(t / segLen));
|
|
63
|
+
const localT = (t - segIndex * segLen) / segLen;
|
|
64
|
+
const p0 = pts[segIndex === 0 ? 0 : segIndex - 1];
|
|
65
|
+
const p1 = pts[segIndex];
|
|
66
|
+
const p2 = pts[segIndex + 1];
|
|
67
|
+
const p3 = pts[segIndex + 2 < pts.length ? segIndex + 2 : pts.length - 1];
|
|
68
|
+
const c1 = { x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 };
|
|
69
|
+
const c2 = { x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 };
|
|
70
|
+
const p = cubic(p1, c1, c2, p2, localT);
|
|
71
|
+
const tan = cubicTangent(p1, c1, c2, p2, localT);
|
|
72
|
+
return { x: p.x, y: p.y, angle: Math.atan2(tan.y, tan.x) };
|
|
73
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte action: animates SVG paths inside the host node using a JS RAF loop.
|
|
3
|
+
*
|
|
4
|
+
* Why JS instead of CSS animations: CSS animations had timing issues — by the
|
|
5
|
+
* time the action finished computing each path's actual length via
|
|
6
|
+
* getTotalLength(), the CSS animation had already started using a fallback
|
|
7
|
+
* value (1000) which doesn't match the path's real length, causing the path
|
|
8
|
+
* to either flicker or stay fully visible. Doing everything in JS is reliable.
|
|
9
|
+
*
|
|
10
|
+
* Modes:
|
|
11
|
+
* • draw — strokes appear from 0 → length (handle reverse to flip)
|
|
12
|
+
* • undraw — strokes disappear from length → 0
|
|
13
|
+
* • draw-undraw — appear then disappear, repeats if loop=true
|
|
14
|
+
* • flow — marching ants. Keeps base dasharray, shifts offset.
|
|
15
|
+
* • none — no animation; restore original attributes.
|
|
16
|
+
*/
|
|
17
|
+
export interface SvgPathDrawParams {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
mode: 'none' | 'draw' | 'undraw' | 'draw-undraw' | 'flow';
|
|
20
|
+
duration: number;
|
|
21
|
+
loop?: boolean;
|
|
22
|
+
reverse?: boolean;
|
|
23
|
+
key?: unknown;
|
|
24
|
+
}
|
|
25
|
+
export declare function traceSvgPaths(node: HTMLElement | SVGElement, params: SvgPathDrawParams): {
|
|
26
|
+
update(p: SvgPathDrawParams): void;
|
|
27
|
+
destroy(): void;
|
|
28
|
+
};
|