Added logo animation to login screen, initial work
This commit is contained in:
@@ -29,7 +29,9 @@
|
|||||||
"Bash(npx -y serve -l 3333 .)",
|
"Bash(npx -y serve -l 3333 .)",
|
||||||
"Bash(npx serve:*)",
|
"Bash(npx serve:*)",
|
||||||
"Bash(timeout /t 3 /nobreak)",
|
"Bash(timeout /t 3 /nobreak)",
|
||||||
"Bash(jq:*)"
|
"Bash(jq:*)",
|
||||||
|
"Bash(git stash:*)",
|
||||||
|
"Bash(npx tsc:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="200" height="450" viewBox="0 0 200 450" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="450" rx="100" fill="#E38B16"/>
|
||||||
|
|
||||||
|
<g transform="translate(50, 100) scale(1.2)">
|
||||||
|
<path d="M10 0 L50 30 L10 60" stroke="white" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<line x1="55" y1="65" x2="85" y2="65" stroke="white" stroke-width="10" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 458 B |
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="200" height="450" viewBox="0 0 200 450" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="450" rx="100" fill="#109E6C"/>
|
||||||
|
|
||||||
|
<g transform="translate(45, 100) scale(1)">
|
||||||
|
<rect x="0" y="60" width="20" height="40" fill="white"/>
|
||||||
|
<rect x="30" y="40" width="20" height="60" fill="white"/>
|
||||||
|
<rect x="60" y="20" width="20" height="80" fill="white"/>
|
||||||
|
<rect x="90" y="0" width="20" height="100" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 477 B |
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="200" height="450" viewBox="0 0 200 450" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="450" rx="100" fill="#0E7A7D"/>
|
||||||
|
|
||||||
|
<g transform="translate(42, 100) scale(1.2)">
|
||||||
|
<path d="M25 70 V0 H55 C80 0 80 35 55 35 H25 M55 35 L85 70 M53 67 L87 38"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="10"
|
||||||
|
stroke-linecap="butt"
|
||||||
|
stroke-linejoin="miter"
|
||||||
|
fill="none"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 459 B |
Binary file not shown.
@@ -0,0 +1,323 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { AbsoluteFill, Easing, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
type PillProps = {
|
||||||
|
src: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
rotation: number;
|
||||||
|
zIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PillTransform = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
rotation: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PILL_WIDTH = 200;
|
||||||
|
const PILL_HEIGHT = 450;
|
||||||
|
const RISE_DURATION_FRAMES = 150;
|
||||||
|
const RISE_SETTLE_PAUSE_FRAMES = 0;
|
||||||
|
const GAP_AFTER_RISE_FRAMES = 30;
|
||||||
|
const FAN_DURATION_FRAMES = 236;
|
||||||
|
const FAN_START_FRAME = RISE_DURATION_FRAMES + RISE_SETTLE_PAUSE_FRAMES + GAP_AFTER_RISE_FRAMES;
|
||||||
|
|
||||||
|
const OVERLAY_BLEND_START_PROGRESS = 0.5;
|
||||||
|
const OVERLAY_BLEND_TRANSITION_FRAMES = 150;
|
||||||
|
const OVERLAP_BLEND_MAX_OPACITY = 0.2;
|
||||||
|
const BACKGROUND_COLOR = "#efefef";
|
||||||
|
const COLOR_CODE = "#E38B16";
|
||||||
|
const COLOR_RX = "#0E7A7D";
|
||||||
|
const COLOR_DATA = "#109E6C";
|
||||||
|
|
||||||
|
const Pill: React.FC<PillProps> = ({ src, x, y, rotation, zIndex }) => {
|
||||||
|
return (
|
||||||
|
<Img
|
||||||
|
src={src}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: PILL_WIDTH,
|
||||||
|
height: PILL_HEIGHT,
|
||||||
|
left: "50%",
|
||||||
|
top: "50%",
|
||||||
|
transform: `translate(-50%, -50%) translate(${x}px, ${y}px) rotate(${rotation}deg)`,
|
||||||
|
transformOrigin: "50% 100%",
|
||||||
|
zIndex,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCapsuleTransform = ({
|
||||||
|
ctx,
|
||||||
|
transform,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
draw,
|
||||||
|
}: {
|
||||||
|
ctx: CanvasRenderingContext2D;
|
||||||
|
transform: PillTransform;
|
||||||
|
videoWidth: number;
|
||||||
|
videoHeight: number;
|
||||||
|
draw: () => void;
|
||||||
|
}) => {
|
||||||
|
ctx.save();
|
||||||
|
// Match the CSS transform order and origin used for the visible pills.
|
||||||
|
ctx.translate(videoWidth / 2, videoHeight / 2);
|
||||||
|
ctx.translate(-PILL_WIDTH / 2, -PILL_HEIGHT / 2);
|
||||||
|
ctx.translate(transform.x, transform.y);
|
||||||
|
ctx.translate(PILL_WIDTH / 2, PILL_HEIGHT);
|
||||||
|
ctx.rotate((transform.rotation * Math.PI) / 180);
|
||||||
|
ctx.translate(-PILL_WIDTH / 2, -PILL_HEIGHT);
|
||||||
|
draw();
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawCapsuleFill = ({
|
||||||
|
ctx,
|
||||||
|
transform,
|
||||||
|
fill,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
}: {
|
||||||
|
ctx: CanvasRenderingContext2D;
|
||||||
|
transform: PillTransform;
|
||||||
|
fill: string;
|
||||||
|
videoWidth: number;
|
||||||
|
videoHeight: number;
|
||||||
|
}) => {
|
||||||
|
withCapsuleTransform({
|
||||||
|
ctx,
|
||||||
|
transform,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
draw: () => {
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(0, 0, PILL_WIDTH, PILL_HEIGHT, PILL_WIDTH / 2);
|
||||||
|
ctx.fill();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPairBlendIntersection = ({
|
||||||
|
outputCtx,
|
||||||
|
pairCtx,
|
||||||
|
maskCtx,
|
||||||
|
colorA,
|
||||||
|
colorB,
|
||||||
|
transformA,
|
||||||
|
transformB,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
}: {
|
||||||
|
outputCtx: CanvasRenderingContext2D;
|
||||||
|
pairCtx: CanvasRenderingContext2D;
|
||||||
|
maskCtx: CanvasRenderingContext2D;
|
||||||
|
colorA: string;
|
||||||
|
colorB: string;
|
||||||
|
transformA: PillTransform;
|
||||||
|
transformB: PillTransform;
|
||||||
|
videoWidth: number;
|
||||||
|
videoHeight: number;
|
||||||
|
}) => {
|
||||||
|
// Build blended color result for the two pills.
|
||||||
|
pairCtx.clearRect(0, 0, videoWidth, videoHeight);
|
||||||
|
pairCtx.globalCompositeOperation = "source-over";
|
||||||
|
drawCapsuleFill({ ctx: pairCtx, transform: transformA, fill: colorA, videoWidth, videoHeight });
|
||||||
|
pairCtx.globalCompositeOperation = "multiply";
|
||||||
|
drawCapsuleFill({ ctx: pairCtx, transform: transformB, fill: colorB, videoWidth, videoHeight });
|
||||||
|
pairCtx.globalCompositeOperation = "source-over";
|
||||||
|
|
||||||
|
// Build hard intersection mask for the same pair.
|
||||||
|
maskCtx.clearRect(0, 0, videoWidth, videoHeight);
|
||||||
|
maskCtx.globalCompositeOperation = "source-over";
|
||||||
|
drawCapsuleFill({ ctx: maskCtx, transform: transformA, fill: "#fff", videoWidth, videoHeight });
|
||||||
|
maskCtx.globalCompositeOperation = "source-in";
|
||||||
|
drawCapsuleFill({ ctx: maskCtx, transform: transformB, fill: "#fff", videoWidth, videoHeight });
|
||||||
|
maskCtx.globalCompositeOperation = "source-over";
|
||||||
|
|
||||||
|
// Keep only overlap area from blended result.
|
||||||
|
pairCtx.globalCompositeOperation = "destination-in";
|
||||||
|
pairCtx.drawImage(maskCtx.canvas, 0, 0);
|
||||||
|
pairCtx.globalCompositeOperation = "source-over";
|
||||||
|
|
||||||
|
// Accumulate pair overlap into final output.
|
||||||
|
outputCtx.drawImage(pairCtx.canvas, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MyComposition: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps, width, height } = useVideoConfig();
|
||||||
|
const overlayBlendEndProgress = Math.min(
|
||||||
|
1,
|
||||||
|
OVERLAY_BLEND_START_PROGRESS + OVERLAY_BLEND_TRANSITION_FRAMES / FAN_DURATION_FRAMES,
|
||||||
|
);
|
||||||
|
|
||||||
|
const codeSrc = staticFile("logo/capsuleCode.svg");
|
||||||
|
const rxSrc = staticFile("logo/capsuleRx.svg");
|
||||||
|
const dataSrc = staticFile("logo/capsuleData.svg");
|
||||||
|
|
||||||
|
const rise = interpolate(frame, [0, RISE_DURATION_FRAMES], [0, 1], {
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fan = spring({
|
||||||
|
frame: frame - FAN_START_FRAME,
|
||||||
|
fps,
|
||||||
|
durationInFrames: FAN_DURATION_FRAMES,
|
||||||
|
config: { damping: 200 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const centerY = interpolate(rise, [0, 1], [560, 76], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftY = interpolate(fan, [0, 1], [centerY, 74], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightY = interpolate(fan, [0, 1], [centerY, 74], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftX = interpolate(fan, [0, 1], [0, -36], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightX = interpolate(fan, [0, 1], [0, 36], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftRotation = interpolate(fan, [0, 1], [0, -50], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightRotation = interpolate(fan, [0, 1], [0, 50], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const overlapBlendProgress =
|
||||||
|
overlayBlendEndProgress >= 1
|
||||||
|
? interpolate(fan, [0, OVERLAY_BLEND_START_PROGRESS, 1], [0, 0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
})
|
||||||
|
: interpolate(
|
||||||
|
fan,
|
||||||
|
[0, OVERLAY_BLEND_START_PROGRESS, overlayBlendEndProgress, 1],
|
||||||
|
[0, 0, 1, 1],
|
||||||
|
{
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlapMaskSrc = useMemo(() => {
|
||||||
|
const centerTransform: PillTransform = { x: 0, y: centerY, rotation: 0 };
|
||||||
|
const leftTransform: PillTransform = { x: leftX, y: leftY, rotation: leftRotation };
|
||||||
|
const rightTransform: PillTransform = { x: rightX, y: rightY, rotation: rightRotation };
|
||||||
|
|
||||||
|
const outputCanvas = document.createElement("canvas");
|
||||||
|
outputCanvas.width = width;
|
||||||
|
outputCanvas.height = height;
|
||||||
|
const outputCtx = outputCanvas.getContext("2d");
|
||||||
|
|
||||||
|
const pairCanvas = document.createElement("canvas");
|
||||||
|
pairCanvas.width = width;
|
||||||
|
pairCanvas.height = height;
|
||||||
|
const pairCtx = pairCanvas.getContext("2d");
|
||||||
|
|
||||||
|
const maskCanvas = document.createElement("canvas");
|
||||||
|
maskCanvas.width = width;
|
||||||
|
maskCanvas.height = height;
|
||||||
|
const maskCtx = maskCanvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!outputCtx || !pairCtx || !maskCtx) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputCtx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
buildPairBlendIntersection({
|
||||||
|
outputCtx,
|
||||||
|
pairCtx,
|
||||||
|
maskCtx,
|
||||||
|
colorA: COLOR_CODE,
|
||||||
|
colorB: COLOR_RX,
|
||||||
|
transformA: centerTransform,
|
||||||
|
transformB: leftTransform,
|
||||||
|
videoWidth: width,
|
||||||
|
videoHeight: height,
|
||||||
|
});
|
||||||
|
|
||||||
|
buildPairBlendIntersection({
|
||||||
|
outputCtx,
|
||||||
|
pairCtx,
|
||||||
|
maskCtx,
|
||||||
|
colorA: COLOR_CODE,
|
||||||
|
colorB: COLOR_DATA,
|
||||||
|
transformA: centerTransform,
|
||||||
|
transformB: rightTransform,
|
||||||
|
videoWidth: width,
|
||||||
|
videoHeight: height,
|
||||||
|
});
|
||||||
|
|
||||||
|
buildPairBlendIntersection({
|
||||||
|
outputCtx,
|
||||||
|
pairCtx,
|
||||||
|
maskCtx,
|
||||||
|
colorA: COLOR_RX,
|
||||||
|
colorB: COLOR_DATA,
|
||||||
|
transformA: leftTransform,
|
||||||
|
transformB: rightTransform,
|
||||||
|
videoWidth: width,
|
||||||
|
videoHeight: height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pixels = outputCtx.getImageData(0, 0, width, height).data;
|
||||||
|
let hasAlphaPixels = false;
|
||||||
|
for (let i = 3; i < pixels.length; i += 4) {
|
||||||
|
if (pixels[i] > 0) {
|
||||||
|
hasAlphaPixels = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAlphaPixels) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputCanvas.toDataURL("image/png");
|
||||||
|
}, [centerY, height, leftRotation, leftX, leftY, rightRotation, rightX, rightY, width]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: BACKGROUND_COLOR }}>
|
||||||
|
<Pill src={codeSrc} x={0} y={centerY} rotation={0} zIndex={3} />
|
||||||
|
<Pill src={rxSrc} x={leftX} y={leftY} rotation={leftRotation} zIndex={1} />
|
||||||
|
<Pill src={dataSrc} x={rightX} y={rightY} rotation={rightRotation} zIndex={2} />
|
||||||
|
{overlapMaskSrc ? (
|
||||||
|
<Img
|
||||||
|
src={overlapMaskSrc}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
opacity: OVERLAP_BLEND_MAX_OPACITY * overlapBlendProgress,
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 20,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import "./index.css";
|
||||||
|
import { Composition } from "remotion";
|
||||||
|
import { MyComposition } from "./Composition";
|
||||||
|
|
||||||
|
export const RemotionRoot: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Composition
|
||||||
|
id="MyComp"
|
||||||
|
component={MyComposition}
|
||||||
|
durationInFrames={380}
|
||||||
|
fps={60}
|
||||||
|
width={1280}
|
||||||
|
height={720}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRoot } from "remotion";
|
||||||
|
import { RemotionRoot } from "./Root";
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="200" height="450" viewBox="0 0 200 450" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="450" rx="100" fill="#E38B16"/>
|
||||||
|
|
||||||
|
<g transform="translate(50, 100) scale(1.2)">
|
||||||
|
<path d="M10 0 L50 30 L10 60" stroke="white" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<line x1="55" y1="65" x2="85" y2="65" stroke="white" stroke-width="10" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 458 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="200" height="450" viewBox="0 0 200 450" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="450" rx="100" fill="#109E6C"/>
|
||||||
|
|
||||||
|
<g transform="translate(45, 100) scale(1)">
|
||||||
|
<rect x="0" y="60" width="20" height="40" fill="white"/>
|
||||||
|
<rect x="30" y="40" width="20" height="60" fill="white"/>
|
||||||
|
<rect x="60" y="20" width="20" height="80" fill="white"/>
|
||||||
|
<rect x="90" y="0" width="20" height="100" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 477 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="200" height="450" viewBox="0 0 200 450" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="450" rx="100" fill="#0E7A7D"/>
|
||||||
|
|
||||||
|
<g transform="translate(42, 100) scale(1.2)">
|
||||||
|
<path d="M25 70 V0 H55 C80 0 80 35 55 35 H25 M55 35 L85 70 M53 67 L87 38"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="10"
|
||||||
|
stroke-linecap="butt"
|
||||||
|
stroke-linejoin="miter"
|
||||||
|
fill="none"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 459 B |
@@ -8,103 +8,125 @@ interface CvmisLogoProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pivot point: bottom-center of the pill stack (in viewBox coords)
|
||||||
|
const PX = 300
|
||||||
|
const PY = 275
|
||||||
|
|
||||||
|
// Build a CSS transform that rotates around (PX, PY) then offsets by dx
|
||||||
|
function fanTransform(rotation: number, dx: number): string {
|
||||||
|
return [
|
||||||
|
`translate(${dx}px, 0px)`,
|
||||||
|
`translate(${PX}px, ${PY}px)`,
|
||||||
|
`rotate(${rotation}deg)`,
|
||||||
|
`translate(${-PX}px, ${-PY}px)`,
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDENTITY_TRANSFORM = fanTransform(0, 0)
|
||||||
|
const FAN_EASING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||||
|
|
||||||
export function CvmisLogo({ size, cssHeight, animated = false, className }: CvmisLogoProps) {
|
export function CvmisLogo({ size, cssHeight, animated = false, className }: CvmisLogoProps) {
|
||||||
const prefersReducedMotion = useReducedMotion()
|
const prefersReducedMotion = useReducedMotion()
|
||||||
const [animationPhase, setAnimationPhase] = useState<'rise' | 'fan' | 'done'>(
|
const [phase, setPhase] = useState<'rising' | 'fanning' | 'done'>(
|
||||||
animated && !prefersReducedMotion ? 'rise' : 'done'
|
animated && !prefersReducedMotion ? 'rising' : 'done'
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!animated || prefersReducedMotion) return
|
if (!animated || prefersReducedMotion) return
|
||||||
|
|
||||||
const riseTimer = setTimeout(() => setAnimationPhase('fan'), 500)
|
const fanTimer = setTimeout(() => setPhase('fanning'), 500)
|
||||||
const fanTimer = setTimeout(() => setAnimationPhase('done'), 1000)
|
const doneTimer = setTimeout(() => setPhase('done'), 1000)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(riseTimer)
|
|
||||||
clearTimeout(fanTimer)
|
clearTimeout(fanTimer)
|
||||||
|
clearTimeout(doneTimer)
|
||||||
}
|
}
|
||||||
}, [animated, prefersReducedMotion])
|
}, [animated, prefersReducedMotion])
|
||||||
|
|
||||||
const skipAnimation = !animated || prefersReducedMotion
|
const skip = !animated || prefersReducedMotion
|
||||||
const showAll = animationPhase === 'fan' || animationPhase === 'done'
|
const isFanned = phase === 'fanning' || phase === 'done'
|
||||||
|
const fanTarget = isFanned || skip
|
||||||
|
|
||||||
|
const leftTransform = fanTarget ? fanTransform(-50, -16) : IDENTITY_TRANSFORM
|
||||||
|
const rightTransform = fanTarget ? fanTransform(50, 16) : IDENTITY_TRANSFORM
|
||||||
|
const fanTransition = skip ? 'none' : `transform 0.6s ${FAN_EASING}`
|
||||||
|
const fanTransitionDelayed = skip ? 'none' : `transform 0.6s ${FAN_EASING} 0.03s`
|
||||||
|
|
||||||
// The original SVG viewBox is 600pt x 506pt with an internal transform of
|
|
||||||
// scale(0.05, -0.05) translate(0, 506). We keep the original coordinate
|
|
||||||
// system and let the outer viewBox handle scaling.
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 600 506"
|
viewBox="0 0 600 300"
|
||||||
height={cssHeight ? undefined : size}
|
height={cssHeight ? undefined : size}
|
||||||
className={className}
|
className={className}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="CVMIS logo"
|
aria-label="CVMIS logo"
|
||||||
style={{ overflow: 'visible', ...(cssHeight ? { height: cssHeight, width: 'auto' } : {}) }}
|
style={{
|
||||||
|
overflow: 'visible',
|
||||||
|
...(cssHeight ? { height: cssHeight, width: 'auto' } : {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<g transform="translate(0,506) scale(0.05,-0.05)" stroke="none">
|
{/* Rise group — all pills rise together from below */}
|
||||||
{/* Capsule: Rx (Pharmacy) - Left — teal, tilts left in fan */}
|
<motion.g
|
||||||
<motion.g
|
initial={skip ? false : { y: 350, opacity: 0 }}
|
||||||
id="capsule-rx"
|
animate={{ y: 0, opacity: 1 }}
|
||||||
fill="#0b7979"
|
transition={{
|
||||||
initial={skipAnimation ? false : { opacity: 0 }}
|
y: { duration: 0.5, ease: [0.33, 1, 0.68, 1] },
|
||||||
animate={
|
opacity: { duration: 0.25 },
|
||||||
skipAnimation
|
}}
|
||||||
? { opacity: 1, rotate: 0, x: 0, y: 0 }
|
>
|
||||||
: showAll
|
{/* Rx pill — teal, fans left (bottom layer) */}
|
||||||
? { opacity: 1, rotate: 0, x: 0, y: 0 }
|
<g style={{ transform: leftTransform, transition: fanTransition }}>
|
||||||
: { opacity: 0 }
|
<g transform="translate(250, 50)">
|
||||||
}
|
<rect width="100" height="225" rx="50" fill="#0E7A7D" />
|
||||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
<g transform="translate(21, 50) scale(0.6)">
|
||||||
style={{ transformOrigin: '2500px 3000px' }}
|
<path
|
||||||
>
|
d="M25 70 V0 H55 C80 0 80 35 55 35 H25 M55 35 L85 70 M53 67 L87 38"
|
||||||
<path d="M2060 6850 c-914 -249 -1279 -1334 -697 -2071 47 -60 198 -225 336 -366 138 -141 256 -265 262 -275 6 -10 150 -160 320 -333 300 -306 1129 -1163 1490 -1542 549 -575 1246 -700 1772 -318 l86 62 -105 35 c-506 172 -872 557 -1036 1089 l-55 179 -11 1003 -12 1003 -550 567 c-780 805 -801 822 -1095 932 -172 65 -531 82 -705 35z m610 -1228 c80 -45 128 -177 97 -261 l-23 -59 99 -21 c147 -31 144 -33 131 87 -10 101 -7 111 54 170 86 83 87 82 102 -73 24 -254 8 -234 213 -270 99 -18 187 -38 194 -45 22 -23 -136 -153 -173 -143 -19 6 -71 16 -115 23 l-82 12 9 -122 c9 -117 6 -126 -57 -191 -80 -83 -99 -74 -99 47 0 52 -6 141 -13 198 l-12 104 -137 31 c-180 41 -244 39 -288 -9 -41 -45 -37 -52 98 -195 l81 -85 -66 -65 -66 -65 -189 200 c-105 110 -232 248 -283 307 l-93 106 159 150 c236 222 314 251 459 169z" />
|
stroke="white"
|
||||||
<path d="M2395 5412 c-112 -103 -113 -108 -37 -180 65 -63 93 -57 185 41 73 77 81 140 26 196 -47 46 -70 39 -174 -57z" />
|
strokeWidth="10"
|
||||||
</motion.g>
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
{/* Capsule: Terminal (Code) - Centre — amber, stays upright */}
|
{/* Data pill — green, fans right (middle layer) */}
|
||||||
<motion.g
|
<g style={{ transform: rightTransform, transition: fanTransitionDelayed }}>
|
||||||
id="capsule-terminal"
|
<g transform="translate(250, 50)">
|
||||||
fill="#d97706"
|
<rect width="100" height="225" rx="50" fill="#109E6C" />
|
||||||
initial={skipAnimation ? false : { opacity: 0 }}
|
<g transform="translate(22.5, 50) scale(0.5)">
|
||||||
animate={
|
<rect x="0" y="60" width="20" height="40" fill="white" />
|
||||||
skipAnimation
|
<rect x="30" y="40" width="20" height="60" fill="white" />
|
||||||
? { opacity: 1, rotate: 0, x: 0, y: 0 }
|
<rect x="60" y="20" width="20" height="80" fill="white" />
|
||||||
: showAll
|
<rect x="90" y="0" width="20" height="100" fill="white" />
|
||||||
? { opacity: 1, rotate: 0, x: 0, y: 0 }
|
</g>
|
||||||
: { opacity: 0 }
|
</g>
|
||||||
}
|
</g>
|
||||||
transition={{ duration: 0.5, ease: 'easeOut', delay: skipAnimation ? 0 : 0.05 }}
|
|
||||||
style={{ transformOrigin: '5500px 5000px' }}
|
|
||||||
>
|
|
||||||
<path d="M5740 8362 c-476 -105 -891 -512 -1015 -997 -45 -173 -54 -3865 -11 -4070 50 -233 182 -483 355 -671 185 -201 701 -447 777 -371 11 11 -100 221 -119 267 -19 46 -18 106 -37 200 -66 317 -11 705 143 1010 120 237 111 226 917 1060 255 264 493 513 528 554 l65 74 -8 916 c-9 1115 -24 1196 -286 1542 -286 377 -851 587 -1309 486z m31 -1595 c115 -118 209 -223 209 -236 0 -12 -97 -118 -215 -236 l-215 -215 -55 48 c-75 64 -76 62 103 244 l159 162 -159 158 c-175 174 -176 176 -115 242 62 65 57 68 288 -167z m825 -613 l-6 -64 -295 -6 -295 -5 0 75 0 76 301 -6 301 -6 -6 -64z" />
|
|
||||||
</motion.g>
|
|
||||||
|
|
||||||
{/* Capsule: Data (Analytics) - Right — green, the "rising" capsule */}
|
{/* Code pill — amber, center (top layer, no fan) */}
|
||||||
<motion.g
|
<g transform="translate(250, 50)">
|
||||||
id="capsule-data"
|
<rect width="100" height="225" rx="50" fill="#E38B16" />
|
||||||
fill="#059669"
|
<g transform="translate(25, 50) scale(0.6)">
|
||||||
initial={
|
<path
|
||||||
skipAnimation
|
d="M10 0 L50 30 L10 60"
|
||||||
? false
|
stroke="white"
|
||||||
: { opacity: 0, scale: 0, y: 2000 }
|
strokeWidth="10"
|
||||||
}
|
strokeLinecap="round"
|
||||||
animate={
|
strokeLinejoin="round"
|
||||||
skipAnimation
|
fill="none"
|
||||||
? { opacity: 1, scale: 1, y: 0, rotate: 0 }
|
/>
|
||||||
: animationPhase === 'rise'
|
<line
|
||||||
? { opacity: 1, scale: 1, y: 0 }
|
x1="55"
|
||||||
: { opacity: 1, scale: 1, y: 0, rotate: 0 }
|
y1="65"
|
||||||
}
|
x2="85"
|
||||||
transition={{
|
y2="65"
|
||||||
duration: 0.5,
|
stroke="white"
|
||||||
ease: 'easeOut',
|
strokeWidth="10"
|
||||||
delay: skipAnimation ? 0 : (animationPhase === 'fan' ? 0.1 : 0),
|
strokeLinecap="round"
|
||||||
}}
|
/>
|
||||||
style={{ transformOrigin: '9000px 3000px' }}
|
</g>
|
||||||
>
|
</g>
|
||||||
<path d="M9380 6850 c-351 -63 -390 -94 -1322 -1027 -1753 -1757 -1929 -1943 -2039 -2162 -455 -906 300 -1962 1305 -1822 381 53 567 178 1165 785 2249 2284 2186 2217 2302 2468 432 933 -380 1945 -1411 1758z m35 -1254 l83 -86 -325 -325 -325 -325 -89 91 -89 91 319 319 c369 370 320 342 426 235z m-502 -59 c88 -86 90 -81 -108 -280 l-175 -176 -86 85 -85 84 175 174 c201 201 192 197 279 113z m1036 -132 c11 -8 47 -44 79 -80 l60 -65 -409 -409 -409 -409 -86 84 -86 84 405 405 c223 224 410 406 416 406 5 0 19 -7 30 -16z m-460 -164 l79 -81 -254 -254 -254 -254 -81 79 c-99 97 -115 63 168 349 276 279 243 263 342 161z" />
|
</motion.g>
|
||||||
</motion.g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
|||||||
>
|
>
|
||||||
<div style={{ marginBottom: '10px' }}>
|
<div style={{ marginBottom: '10px' }}>
|
||||||
<CvmisLogo
|
<CvmisLogo
|
||||||
cssHeight="clamp(48px, 4vw, 64px)"
|
cssHeight="clamp(80px, 8vw, 120px)"
|
||||||
animated={true}
|
animated={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user