Files
openmaic/OpenMAIC/lib/choreography/descriptors/laser.ts
2026-08-16 14:58:47 +08:00

139 lines
4.5 KiB
TypeScript

import type { AnimationDescriptor } from './types';
/**
* laser.v1 — a laser dot flies in from the nearest off-screen corner to the
* element center, with a pulsing ring and a glowing core.
*
* Values captured verbatim from the `LaserOverlay` effect component
* (`motion/react`):
* - dot fly-in: left/top 500ms expo-out `[0.22,1,0.36,1]`; opacity 150ms;
* start corner is `center > 50 ? 105 : -5` (percent) per axis.
* - exit: 250ms ease-in `[0.4,0,1,1]` back to the start corner (position +
* opacity).
* - ring: infinite pulse, scale 1→2.8, opacity 0.6→0, 1500ms easeOut, 300ms
* repeat delay.
* - core: 10px square (`w-2.5 h-2.5`), glow `0 0 8px 2px {color}60`.
*
* Static geometry captured too, so a literal (non-React) renderer matches the
* app rather than drawing an offset square: the dot group is centered on the
* target via `translate(-50%, -50%)` (`-translate-x/y-1/2`), and both the ring
* and core are circles (`rounded-full` → `borderRadius: 9999px`); the ring is
* `position: absolute; inset: 0` inside the group.
*
* Color default `#ff0000` — the app store's laserOptions default
* (`lib/store/canvas.ts`), which the caller passes in; the component's own
* fallback `#ff3b30` is only used when no options exist, which does not happen
* at playback time.
*/
export const laserV1: AnimationDescriptor = {
id: 'laser.v1',
version: 1,
effect: 'laser',
params: { color: '#ff0000' },
zIndex: 101,
layers: [
{
id: 'dot',
// The dot group is anchored at its own center on the target coordinate.
staticProps: { transform: 'translate(-50%, -50%)' },
tracks: [
{
property: 'left',
from: { axis: 'centerX', threshold: 50, whenAbove: 105, whenBelow: -5 },
to: { ref: 'centerX' },
durationMs: 500,
easing: { type: 'cubicBezier', points: [0.22, 1, 0.36, 1] },
phase: 'enter',
},
{
property: 'top',
from: { axis: 'centerY', threshold: 50, whenAbove: 105, whenBelow: -5 },
to: { ref: 'centerY' },
durationMs: 500,
easing: { type: 'cubicBezier', points: [0.22, 1, 0.36, 1] },
phase: 'enter',
},
{
property: 'opacity',
from: 0,
to: 1,
durationMs: 150,
phase: 'enter',
},
{
property: 'left',
from: { ref: 'centerX' },
to: { axis: 'centerX', threshold: 50, whenAbove: 105, whenBelow: -5 },
durationMs: 250,
easing: { type: 'cubicBezier', points: [0.4, 0, 1, 1] },
phase: 'exit',
},
{
property: 'top',
from: { ref: 'centerY' },
to: { axis: 'centerY', threshold: 50, whenAbove: 105, whenBelow: -5 },
durationMs: 250,
easing: { type: 'cubicBezier', points: [0.4, 0, 1, 1] },
phase: 'exit',
},
{
property: 'opacity',
from: 1,
to: 0,
durationMs: 250,
easing: { type: 'cubicBezier', points: [0.4, 0, 1, 1] },
phase: 'exit',
},
],
},
{
id: 'ring',
// Nested inside the animated dot wrapper in the source, so it rides the
// dot's fly-in/exit position + fade, then adds its own scale/opacity pulse.
inheritsFrom: { parentId: 'dot', props: ['left', 'top', 'opacity'] },
// Circular pulse ring, absolutely positioned to fill the dot group.
staticProps: {
position: 'absolute',
inset: 0,
borderRadius: 9999,
border: '1.5px solid {color}',
},
tracks: [
{
property: 'scale',
from: 1,
to: 2.8,
durationMs: 1500,
easing: { type: 'named', name: 'easeOut' },
repeat: 'infinite',
repeatDelayMs: 300,
},
{
property: 'opacity',
from: 0.6,
to: 0,
durationMs: 1500,
easing: { type: 'named', name: 'easeOut' },
repeat: 'infinite',
repeatDelayMs: 300,
},
],
},
{
id: 'core',
// Nested inside the animated dot wrapper too — rides the dot's fly-in/exit
// position + fade. It has no animation of its own.
inheritsFrom: { parentId: 'dot', props: ['left', 'top', 'opacity'] },
// Circular light core (rounded-full).
staticProps: {
width: 10,
height: 10,
borderRadius: 9999,
backgroundColor: '{color}',
boxShadow: '0 0 8px 2px {color}60',
},
tracks: [],
},
],
};