diff --git a/src/stories/components/AnimationDemo/Scene/Scene.module.css b/src/stories/components/AnimationDemo/Scene/Scene.module.css index 5e1d588..a3ffa30 100644 --- a/src/stories/components/AnimationDemo/Scene/Scene.module.css +++ b/src/stories/components/AnimationDemo/Scene/Scene.module.css @@ -1,38 +1,137 @@ .root { + position: relative; width: 100%; height: 100%; + overflow: hidden; box-sizing: border-box; + isolation: isolate; +} + +.layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.layerSky { + z-index: 0; +} + +.layerSun { + z-index: 1; +} + +.layerMoon { + z-index: 2; +} + +.layerSea { + z-index: 3; +} + +.layerBeam { + z-index: 4; +} + +.layerHills { + z-index: 5; +} + +.svg { + display: block; + width: 100%; + height: 100%; } .transition { transition: fill 1.15s ease, stop-color 1.15s ease, - opacity 1.15s ease, - transform 1.15s ease, - filter 1.15s ease; + opacity 1.15s ease; } .celestial { + will-change: transform, opacity; transform-box: fill-box; transform-origin: center center; - transition: - transform 1.2s ease, - opacity 1.2s ease, - filter 1.2s ease; } -.sun, -.moon, -.seaBeam { - will-change: transform, opacity; +.sun { + transition: + transform 1s ease-out, + opacity 1s ease-out; } -.seaBeam { +.sunGlow { transform-box: fill-box; - transform-origin: center 38%; + transform-origin: center center; + animation: sunGlowFloat 36s linear infinite; +} + +.moon { transition: - opacity 1.2s ease, - transform 1.2s ease, - filter 1.2s ease; + transform 1s ease-out, + opacity 1s ease-out; +} + +.beam { + will-change: opacity; + transition: opacity 1s ease; +} + +.root[data-mode='sunset'] .sun { + transition: + transform 1s ease-in-out, + opacity 1s ease-in-out; +} + +.root[data-mode='night'] .sun { + transition: + transform 0.5s ease-in, + opacity 0.5s ease-in; +} + +.root[data-mode='sunrise'] .sun { + transition: + transform 1s ease-out, + opacity 1s ease-out; +} + +.root[data-mode='day'] .moon, +.root[data-mode='sunset'] .moon { + transition: + transform 0.5s ease-in, + opacity 0.5s ease-in; +} + +.root[data-mode='night'] .moon { + transition: + transform 1s ease-out, + opacity 1s ease-out; +} + +.root[data-mode='sunrise'] .moon { + transition: + transform 0.5s ease-in, + opacity 0.5s ease-in; +} + +@keyframes sunGlowFloat { + 0% { + transform: rotate(0deg) scale(0.96); + } + + 50% { + transform: rotate(180deg) scale(1.04); + } + + 100% { + transform: rotate(360deg) scale(0.96); + } +} + +@media (prefers-reduced-motion: reduce) { + .sunGlow { + animation: none; + } } diff --git a/src/stories/components/AnimationDemo/Scene/Scene.test.tsx b/src/stories/components/AnimationDemo/Scene/Scene.test.tsx index f180b50..86eecd1 100644 --- a/src/stories/components/AnimationDemo/Scene/Scene.test.tsx +++ b/src/stories/components/AnimationDemo/Scene/Scene.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { Scene } from './Scene'; describe('Scene', () => { - it('namespaces SVG defs per instance to avoid id collisions', () => { + it('namespaces per-instance defs and masks to avoid collisions across layered svgs', () => { const { container } = render( <> @@ -12,16 +12,21 @@ describe('Scene', () => { , ); - const svgElements = Array.from(container.querySelectorAll('svg')); - const skyRects = Array.from(container.querySelectorAll('svg > rect:first-of-type')); - const gradientIds = Array.from(container.querySelectorAll('linearGradient')).map(node => + const sceneRoots = Array.from(container.querySelectorAll('[data-mode]')); + const gradientIds = Array.from( + container.querySelectorAll('linearGradient, radialGradient'), + ).map(node => node.getAttribute('id')); + const maskIds = Array.from(container.querySelectorAll('mask')).map(node => node.getAttribute('id'), ); + const daySkyRect = sceneRoots[0]?.querySelector('svg rect'); + const nightSkyRect = sceneRoots[1]?.querySelector('svg rect'); - expect(svgElements).toHaveLength(2); + expect(sceneRoots).toHaveLength(2); expect(new Set(gradientIds).size).toBe(gradientIds.length); - expect(skyRects[0]?.getAttribute('fill')).toMatch(/^url\(#.+-sky\)$/); - expect(skyRects[1]?.getAttribute('fill')).toMatch(/^url\(#.+-sky\)$/); - expect(skyRects[0]?.getAttribute('fill')).not.toBe(skyRects[1]?.getAttribute('fill')); + expect(new Set(maskIds).size).toBe(maskIds.length); + expect(daySkyRect?.getAttribute('fill')).toMatch(/^url\(#.+-sky\)$/); + expect(nightSkyRect?.getAttribute('fill')).toMatch(/^url\(#.+-sky\)$/); + expect(daySkyRect?.getAttribute('fill')).not.toBe(nightSkyRect?.getAttribute('fill')); }); }); diff --git a/src/stories/components/AnimationDemo/Scene/Scene.tsx b/src/stories/components/AnimationDemo/Scene/Scene.tsx index b33bc07..c1e5737 100644 --- a/src/stories/components/AnimationDemo/Scene/Scene.tsx +++ b/src/stories/components/AnimationDemo/Scene/Scene.tsx @@ -1,4 +1,7 @@ import React from 'react'; +import seaBeamLargeMask from './assets/sea-beam-large.png'; +import seaBeamSmallMask from './assets/sea-beam-small.png'; +import sunGlowMask from './assets/sun-glow.png'; import { classNames } from '../classNames'; import styles from './Scene.module.css'; import type { AnimationMode } from '../types'; @@ -19,6 +22,7 @@ type ScenePreset = { seaBeamTop: string; seaBeamBottom: string; seaBeamOpacity: number; + // Kept as source-of-truth from the filtered SVG version; runtime no longer scales the beam. seaBeamScale: number; hillBack: string; hillFrontTop: string; @@ -35,7 +39,22 @@ type ScenePreset = { moonTranslateY: number; }; +const sceneWidth = 1024; +const sceneHeight = 768; +const viewBox = `0 0 ${sceneWidth} ${sceneHeight}`; const horizonY = 281.97; +const horizonOverlap = 2; +const sunCenterX = 512.16; +const sunRadius = 41; +const sunGlowSize = 300; +const sunGlowX = sunCenterX - sunGlowSize / 2; +const sunGlowY = horizonY - sunGlowSize / 2; +const moonCenterY = 141.99; +const moonGlowRadius = 150; +const beamX = 267.34; +const beamY = 261.74; +const beamWidth = 489.32; +const beamHeight = 195.22; const presets: Record = { day: { @@ -151,261 +170,285 @@ const presets: Record = { export function Scene({ mode }: SceneProps) { const preset = presets[mode]; const idPrefix = React.useId().replace(/:/g, ''); - const sceneId = `${idPrefix}-scene`; - const sunHaloFilterId = `${idPrefix}-sun-halo-filter`; - const moonFormFilterId = `${idPrefix}-moon-form-filter`; - const moonSeaGlareFilterId = `${idPrefix}-moon-sea-glare-filter`; const skyGradientId = `${idPrefix}-sky`; const seaGradientId = `${idPrefix}-sea`; - const hillFrontGradientId = `${idPrefix}-hill-front-grad`; - const moonGradientId = `${idPrefix}-moon-grad`; - const seaBeamGradientId = `${idPrefix}-sea-beam-grad`; - const seaGlareGradientId = `${idPrefix}-sea-glare-grad`; - const seaMaskId = `${idPrefix}-sea-mask`; + const hillFrontGradientId = `${idPrefix}-hill-front`; + const sunCoreGradientId = `${idPrefix}-sun-core`; + const moonGlowGradientId = `${idPrefix}-moon-glow`; + const moonGradientId = `${idPrefix}-moon`; + const seaGlareGradientId = `${idPrefix}-sea-glare`; + const showLargeBeam = mode === 'sunset' || mode === 'sunrise'; + const showSmallBeam = mode === 'night'; return (
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + +
+ +
+ + + + + + + + + - - - - - + +
+ +
+ + + + + + + + + + + + + + - - - - - - - - - - - + + +
- + - + + + + + + + + + + + + - - + + + - + - - - + + +
+ + + + + + + + - - - - - - - - - - - + +
); } diff --git a/src/stories/components/AnimationDemo/Scene/assets/moon-glow.png b/src/stories/components/AnimationDemo/Scene/assets/moon-glow.png new file mode 100644 index 0000000..1ef31a4 Binary files /dev/null and b/src/stories/components/AnimationDemo/Scene/assets/moon-glow.png differ diff --git a/src/stories/components/AnimationDemo/Scene/assets/sea-beam-large.png b/src/stories/components/AnimationDemo/Scene/assets/sea-beam-large.png new file mode 100644 index 0000000..322816c Binary files /dev/null and b/src/stories/components/AnimationDemo/Scene/assets/sea-beam-large.png differ diff --git a/src/stories/components/AnimationDemo/Scene/assets/sea-beam-small.png b/src/stories/components/AnimationDemo/Scene/assets/sea-beam-small.png new file mode 100644 index 0000000..afb45ad Binary files /dev/null and b/src/stories/components/AnimationDemo/Scene/assets/sea-beam-small.png differ diff --git a/src/stories/components/AnimationDemo/Scene/assets/sun-glow.png b/src/stories/components/AnimationDemo/Scene/assets/sun-glow.png new file mode 100644 index 0000000..8751fe6 Binary files /dev/null and b/src/stories/components/AnimationDemo/Scene/assets/sun-glow.png differ diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts new file mode 100644 index 0000000..fc781e8 --- /dev/null +++ b/src/types/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const src: string; + export default src; +}