@linhey/rhine-design 0.5.0 → 0.8.0
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/CHANGELOG.md +39 -0
- package/README.md +26 -8
- package/components/Actions.tsx +18 -1
- package/components/AppShell.tsx +46 -17
- package/components/MOTION.md +9 -1
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +35 -0
- package/components/Overlays.tsx +37 -8
- package/components/README.md +30 -2
- package/components/StatusTimeline.tsx +72 -6
- package/components/StatusTimelineList.tsx +38 -0
- package/components/ThemePicker.tsx +49 -0
- package/components/assets/orbit-bird.svg +578 -0
- package/components/assets/orbit-crystal.svg +11 -0
- package/components/assets/orbit-donut.svg +16 -0
- package/components/assets/theme-previews/arknights.png +0 -0
- package/components/assets/theme-previews/manifest.json +24 -0
- package/components/assets/theme-previews/neo-brutalism.png +0 -0
- package/components/assets/theme-previews/rhine.png +0 -0
- package/components/common.css +3 -3
- package/components/components.tsx +3 -3
- package/components/dialog-settings.css +9 -0
- package/components/extensions.css +8 -32
- package/components/index.ts +13 -5
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +120 -0
- package/components/orbitBirdFlight.ts +34 -0
- package/components/orbitBirdGeometry.ts +158 -0
- package/components/orbitDonut.ts +134 -0
- package/components/orbitDonutThree.ts +208 -0
- package/components/orbitObjectMaterials.ts +76 -0
- package/components/orbitObjects.ts +71 -0
- package/components/orbitObjectsThree.ts +215 -0
- package/components/orbitProjection.ts +21 -0
- package/components/orbitRenderer.ts +347 -0
- package/components/styles.css +4 -1
- package/components/theme-neo-brutalism.css +3 -2
- package/components/theme-picker.css +17 -0
- package/components/theme.tsx +2 -8
- package/components/themeMetadata.ts +7 -0
- package/components/timeline.css +17 -6
- package/components/useFloatingScale.ts +96 -0
- package/docs/APPLICATION_COMPONENTS.md +52 -3
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
- package/docs/DATE_RANGE_FILTER.md +60 -0
- package/docs/DESIGN_SYSTEM.md +7 -0
- package/docs/FLOATING_OVERLAYS.md +15 -0
- package/docs/INTERACTION_PATTERNS.md +5 -1
- package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
- package/docs/ORBIT_BACKDROP.md +73 -0
- package/docs/SETTINGS_DIALOG.md +21 -0
- package/docs/THEME_PICKER.md +26 -0
- package/docs/THEMING.md +2 -0
- package/docs/assets/compact-account-neo.png +0 -0
- package/docs/assets/neutron-star-original.png +0 -0
- package/docs/assets/neutron-star-theme.png +0 -0
- package/docs/assets/orbit-backdrop.png +0 -0
- package/docs/assets/orbit-bird.png +0 -0
- package/docs/assets/orbit-crystal.png +0 -0
- package/docs/assets/orbit-strawberry.png +0 -0
- package/package.json +8 -3
- package/preview/CompactAccountGallery.tsx +178 -0
- package/preview/DateRangeFilterGallery.tsx +109 -0
- package/preview/ExtensionsGallery.tsx +27 -18
- package/preview/GalleryShell.tsx +3 -3
- package/preview/MonitoringGallery.tsx +2 -0
- package/preview/NeutronStarGallery.tsx +56 -0
- package/preview/OrbitGallery.tsx +50 -0
- package/preview/OverlayBoundsGallery.tsx +49 -0
- package/preview/SettingsDialogGallery.tsx +33 -0
- package/preview/ThemePickerGallery.tsx +24 -0
- package/preview/ThemeSample.tsx +14 -0
- package/preview/TimelineListGallery.tsx +36 -0
- package/preview/extensions.css +9 -0
- package/preview/main.tsx +19 -4
- package/preview/preview.css +8 -0
- package/preview/theme-sample.html +1 -0
- package/scripts/check-overlay-bounds.js +76 -0
- package/scripts/check-settings-theme.js +88 -0
- package/skills/rhine-design/SKILL.md +3 -2
- package/skills/rhine-design/references/API_MAP.md +14 -1
- package/skills/rhine-design/references/TASK_MAP.md +8 -1
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/AppShellControls.test.tsx +82 -0
- package/tests/ButtonLink.test.tsx +94 -0
- package/tests/CompactAccountGallery.test.tsx +116 -0
- package/tests/DateRangeFilterGallery.test.tsx +85 -0
- package/tests/NeutronStarBackdrop.test.tsx +288 -0
- package/tests/NeutronStarGallery.test.tsx +62 -0
- package/tests/NeutronStarImport.test.tsx +15 -0
- package/tests/OrbitBackdrop.test.tsx +420 -0
- package/tests/OrbitDonutThree.test.tsx +91 -0
- package/tests/OrbitObjectsThree.test.tsx +164 -0
- package/tests/SettingsDialog.test.tsx +66 -0
- package/tests/StatusTimelineList.test.tsx +63 -0
- package/tests/ThemePicker.test.tsx +30 -0
- package/tests/TimelineLegend.test.tsx +60 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACESFilmicToneMapping, AdditiveBlending, BufferGeometry, Color, DirectionalLight,
|
|
3
|
+
DoubleSide, EdgesGeometry, Float32BufferAttribute, Group, HemisphereLight, InstancedMesh,
|
|
4
|
+
LineBasicMaterial, LineSegments, Material, Mesh, MeshBasicMaterial, MeshPhysicalMaterial,
|
|
5
|
+
MeshStandardMaterial, NormalBlending, Object3D, OctahedronGeometry, PerspectiveCamera, PlaneGeometry,
|
|
6
|
+
Points, Scene, ShaderMaterial, SRGBColorSpace, Texture, TorusGeometry, WebGLRenderer,
|
|
7
|
+
} from 'three';
|
|
8
|
+
import { paperTexture, mineralMaterial, studioEnvironment } from './orbitObjectMaterials';
|
|
9
|
+
import { morphPaperPoint, paperBirdFolds, type PaperFold } from './orbitBirdGeometry';
|
|
10
|
+
import { paperBirdFlight, paperMorphAmount } from './orbitBirdFlight';
|
|
11
|
+
|
|
12
|
+
export type OrbitObjectKind = 'paper' | 'technical';
|
|
13
|
+
export interface OrbitObjectArtwork {
|
|
14
|
+
canvas: HTMLCanvasElement;
|
|
15
|
+
draw(width: number, height: number, time: number): boolean;
|
|
16
|
+
dispose(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The host supplies elapsed visible time; a theme object never schedules its own frames. */
|
|
20
|
+
export function createOrbitObject(kind: OrbitObjectKind, invalidate: () => void): OrbitObjectArtwork {
|
|
21
|
+
const geometries = new Set<BufferGeometry>(), materials = new Set<Material>(), instances = new Set<InstancedMesh>(), textures = new Set<Texture>();
|
|
22
|
+
const geometry = <T extends BufferGeometry>(value: T) => { geometries.add(value); return value; };
|
|
23
|
+
const material = <T extends Material>(value: T) => { materials.add(value); return value; };
|
|
24
|
+
const texture = <T extends Texture>(value: T) => { textures.add(value); return value; };
|
|
25
|
+
const canvas = document.createElement('canvas');
|
|
26
|
+
canvas.className = `rhine-orbit-canvas rhine-orbit-object-webgl rhine-orbit-${kind}-webgl`;
|
|
27
|
+
canvas.setAttribute('aria-hidden', 'true'); canvas.dataset.orbitArtwork = kind;
|
|
28
|
+
let renderer: WebGLRenderer | undefined, disposed = false, lost = false;
|
|
29
|
+
function contextLost(event: Event) { event.preventDefault(); lost = true; invalidate(); }
|
|
30
|
+
function contextRestored() { lost = false; invalidate(); }
|
|
31
|
+
function dispose() {
|
|
32
|
+
if (disposed) return;
|
|
33
|
+
disposed = true;
|
|
34
|
+
canvas.removeEventListener('webglcontextlost', contextLost); canvas.removeEventListener('webglcontextrestored', contextRestored);
|
|
35
|
+
instances.forEach(value => value.dispose()); geometries.forEach(value => value.dispose()); materials.forEach(value => value.dispose()); textures.forEach(value => value.dispose());
|
|
36
|
+
renderer?.dispose(); renderer?.forceContextLoss(); canvas.remove();
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: 'low-power' });
|
|
40
|
+
renderer.setClearColor(0x000000, 0); renderer.outputColorSpace = SRGBColorSpace;
|
|
41
|
+
renderer.toneMapping = ACESFilmicToneMapping; renderer.toneMappingExposure = 1.12;
|
|
42
|
+
const bird = kind === 'paper', scene = new Scene(), camera = new PerspectiveCamera(35, 1, .1, 40);
|
|
43
|
+
scene.environment = texture(studioEnvironment());
|
|
44
|
+
scene.name = bird ? 'origami-paper-bird' : 'originium-cluster';
|
|
45
|
+
scene.add(new HemisphereLight(bird ? '#f5fff1' : '#d8e4dc', bird ? '#42654d' : '#151c24', bird ? .9 : .65));
|
|
46
|
+
const key = new DirectionalLight('#fff5da', 2.4); key.position.set(-3, 5, 4); scene.add(key);
|
|
47
|
+
const rim = new DirectionalLight(bird ? '#b6e0c8' : '#ffa634', 2.8); rim.position.set(3, 1, -2); scene.add(rim);
|
|
48
|
+
const fill = new DirectionalLight('#d4eeee', .7); fill.position.set(2, -2, 3); scene.add(fill);
|
|
49
|
+
const frame = new Group(); frame.rotation.set(bird ? .53 : .13, bird ? -.65 : -.32, bird ? -.12 : .08); scene.add(frame);
|
|
50
|
+
const object = new Group(); object.name = 'theme-object'; frame.add(object);
|
|
51
|
+
const dummy = new Object3D();
|
|
52
|
+
const updates: Array<(time: number) => void> = [];
|
|
53
|
+
let seed = bird ? 70231 : 39017;
|
|
54
|
+
const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
|
|
55
|
+
const mesh = (g: BufferGeometry, m: Material, parent = object) => { const value = new Mesh(g, m); parent.add(value); return value; };
|
|
56
|
+
const instanced = (g: BufferGeometry, m: Material, count: number, name: string, parent = object) => {
|
|
57
|
+
const value = new InstancedMesh(g, m, count); value.name = name; instances.add(value); parent.add(value); return value;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (bird) {
|
|
61
|
+
const grain = texture(paperTexture());
|
|
62
|
+
const paper = material(new MeshPhysicalMaterial({ color: '#fffdf0', map: grain, bumpMap: grain, bumpScale: .008, roughness: .87, metalness: 0, side: DoubleSide }));
|
|
63
|
+
const shadedPaper = material(new MeshStandardMaterial({ color: '#dce4d5', map: grain, bumpMap: grain, bumpScale: .006, roughness: .92, side: DoubleSide }));
|
|
64
|
+
const innerPaper = material(new MeshStandardMaterial({ color: '#a7b9a7', map: grain, roughness: .93, side: DoubleSide }));
|
|
65
|
+
const crease = material(new LineBasicMaterial({ color: '#6f8572', transparent: true, opacity: .13 }));
|
|
66
|
+
function foldedSheet(fold: PaperFold, target: PaperFold, parent: Group) {
|
|
67
|
+
const { points, faces, name } = fold;
|
|
68
|
+
const positions: number[] = [], uvs: number[] = [];
|
|
69
|
+
for (const face of faces) for (const i of face) { positions.push(...points[i]); uvs.push(points[i][0] * .5 + .5, points[i][2] * .5 + .5); }
|
|
70
|
+
const g = geometry(new BufferGeometry()); g.setAttribute('position', new Float32BufferAttribute(positions, 3)); g.setAttribute('uv', new Float32BufferAttribute(uvs, 2)); g.computeVertexNormals();
|
|
71
|
+
faces.forEach((_, i) => g.addGroup(i * 3, 3, i % 10 === 4 ? 2 : i % 4 === 2 ? 1 : 0));
|
|
72
|
+
const sheet = new Mesh(g, [paper, shadedPaper, innerPaper]); sheet.name = name; parent.add(sheet);
|
|
73
|
+
const edges = geometry(new EdgesGeometry(g, 8)), edgePositions = edges.getAttribute('position');
|
|
74
|
+
const edgeIndices = Array.from({ length: edgePositions.count }, (_, i) => points.findIndex(p =>
|
|
75
|
+
Math.abs(p[0] - edgePositions.getX(i)) < 1e-6 && Math.abs(p[1] - edgePositions.getY(i)) < 1e-6 && Math.abs(p[2] - edgePositions.getZ(i)) < 1e-6));
|
|
76
|
+
const lines = new LineSegments(edges, crease); parent.add(lines);
|
|
77
|
+
// Reuse the same buffers, materials and crease endpoints for the entire transformation.
|
|
78
|
+
let previousAmount = -1;
|
|
79
|
+
updates.push(time => {
|
|
80
|
+
const amount = paperMorphAmount(time);
|
|
81
|
+
if (amount === previousAmount) return;
|
|
82
|
+
const vertices = points.map((p, i) => morphPaperPoint(p, target.points[i], amount, fold.wing, fold.name));
|
|
83
|
+
const positions = g.getAttribute('position');
|
|
84
|
+
faces.forEach((face, f) => face.forEach((index, v) => positions.setXYZ(f * 3 + v, ...vertices[index])));
|
|
85
|
+
edgeIndices.forEach((index, i) => edgePositions.setXYZ(i, ...vertices[index]));
|
|
86
|
+
positions.needsUpdate = true; edgePositions.needsUpdate = true;
|
|
87
|
+
g.computeVertexNormals(); g.computeBoundingSphere(); edges.computeBoundingSphere();
|
|
88
|
+
previousAmount = amount;
|
|
89
|
+
});
|
|
90
|
+
return sheet;
|
|
91
|
+
}
|
|
92
|
+
const wings = new Map<number, Group>();
|
|
93
|
+
for (const direction of [-1, 1]) {
|
|
94
|
+
const wing = new Group(); wing.name = direction < 0 ? 'paper-wing-left' : 'paper-wing-right'; object.add(wing); wings.set(direction, wing);
|
|
95
|
+
updates.push(time => { wing.rotation.z = direction * paperBirdFlight(time).wing; });
|
|
96
|
+
}
|
|
97
|
+
const targets = paperBirdFolds('butterfly');
|
|
98
|
+
paperBirdFolds().forEach((fold, i) => foldedSheet(fold, targets[i], fold.wing ? wings.get(fold.wing)! : object));
|
|
99
|
+
const scraps = instanced(geometry(new PlaneGeometry(.035,.018)), shadedPaper, 42, 'floating-paper-flecks', frame);
|
|
100
|
+
scraps.frustumCulled = false;
|
|
101
|
+
const seeds = Array.from({length:scraps.count},()=>({a:random()*Math.PI*2,r:1.15+random()*.7,y:random()*2.1-1.05,s:.4+random()*.9}));
|
|
102
|
+
updates.push(time=>{
|
|
103
|
+
const flight = paperBirdFlight(time);
|
|
104
|
+
object.position.set(flight.x, flight.y, flight.z);
|
|
105
|
+
object.rotation.set(flight.pitch, flight.yaw, flight.bank);
|
|
106
|
+
seeds.forEach((p,i)=>{const a=p.a+time*.06, wake=paperBirdFlight(time-i*.06);dummy.position.set(wake.x+Math.cos(a)*p.r*.65,wake.y+p.y*.7, wake.z- .25-i*.017+Math.sin(a)*p.r*.25);dummy.rotation.set(p.a+time*.16,a,time*.1+p.a);dummy.scale.setScalar(p.s);dummy.updateMatrix();scraps.setMatrixAt(i,dummy.matrix);});
|
|
107
|
+
scraps.instanceMatrix.needsUpdate=true;
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
function crystalGeometry() {
|
|
111
|
+
const positions: number[] = [];
|
|
112
|
+
const rings = [{ y: -1.03, r: .24 }, { y: -.78, r: .72 }, { y: .57, r: 1 }, { y: .72, r: .92 }, { y: 1.46, r: .06 }];
|
|
113
|
+
const vertex = (i: number, level: number) => {
|
|
114
|
+
const a = Math.floor(i / 2) * Math.PI / 3 + (i % 2 ? .055 : -.055);
|
|
115
|
+
return [Math.cos(a) * rings[level].r, rings[level].y, Math.sin(a) * rings[level].r];
|
|
116
|
+
};
|
|
117
|
+
const triangle = (a: number[], b: number[], c: number[]) => positions.push(...a, ...b, ...c);
|
|
118
|
+
for (let i = 0; i < 12; i++) {
|
|
119
|
+
const j = (i + 1) % 12;
|
|
120
|
+
for (let level = 0; level < rings.length - 1; level++) {
|
|
121
|
+
const p = vertex(i, level), q = vertex(j, level), r = vertex(i, level + 1), t = vertex(j, level + 1);
|
|
122
|
+
triangle(p, r, q); triangle(q, r, t);
|
|
123
|
+
}
|
|
124
|
+
triangle(vertex(j, 0), [0, -1.1, 0], vertex(i, 0));
|
|
125
|
+
triangle(vertex(i, 4), [0, 1.51, 0], vertex(j, 4));
|
|
126
|
+
}
|
|
127
|
+
const g = geometry(new BufferGeometry()); g.setAttribute('position', new Float32BufferAttribute(positions, 3)); g.computeVertexNormals(); return g;
|
|
128
|
+
}
|
|
129
|
+
const crystal = crystalGeometry();
|
|
130
|
+
const facetMaterial = material(mineralMaterial());
|
|
131
|
+
const edgeMaterial = material(new LineBasicMaterial({ color: '#9e885b', transparent: true, opacity: .16 }));
|
|
132
|
+
const edges = geometry(new EdgesGeometry(crystal, 25));
|
|
133
|
+
const shards = [
|
|
134
|
+
{ x: 0, y: .04, z: 0, s: .88, rx: .04, rz: -.08 },
|
|
135
|
+
{ x: -.48, y: -.38, z: .12, s: .56, rx: .12, rz: .45 },
|
|
136
|
+
{ x: .47, y: -.35, z: .13, s: .64, rx: -.14, rz: -.4 },
|
|
137
|
+
{ x: -.18, y: -.58, z: -.42, s: .48, rx: -.4, rz: .15 },
|
|
138
|
+
{ x: .18, y: -.65, z: .48, s: .42, rx: .38, rz: -.1 },
|
|
139
|
+
];
|
|
140
|
+
shards.forEach((p, i) => {
|
|
141
|
+
const shard = new Group(); shard.name = `originium-facet-${i}`; shard.position.set(p.x, p.y, p.z); shard.rotation.set(p.rx, i * .8, p.rz); shard.scale.set(.37 * p.s, p.s, .37 * p.s); object.add(shard);
|
|
142
|
+
mesh(crystal, facetMaterial, shard); shard.add(new LineSegments(edges, edgeMaterial));
|
|
143
|
+
});
|
|
144
|
+
const fragments = instanced(geometry(new OctahedronGeometry(.038, 0)), facetMaterial, 48, 'floating-fragments');
|
|
145
|
+
// The shader and instanced particles move beyond their initial CPU coordinates.
|
|
146
|
+
fragments.frustumCulled = false;
|
|
147
|
+
const fragmentSeeds = Array.from({ length: fragments.count }, () => ({ phase: random() * Math.PI * 2, r: 1.02 + random() * .58, y: random() * 2.35 - 1.1, size: .28 + random() * 1.15, speed: .08 + random() * .08 }));
|
|
148
|
+
updates.push(time => {
|
|
149
|
+
fragmentSeeds.forEach((p, i) => {
|
|
150
|
+
const a = p.phase + time * p.speed;
|
|
151
|
+
dummy.position.set(Math.cos(a) * p.r, p.y + Math.sin(time * .4 + p.phase) * .12, Math.sin(a) * p.r * .5);
|
|
152
|
+
dummy.rotation.set(a * .5, a, p.phase + time * .12); dummy.scale.set(.65 * p.size, 1.5 * p.size, p.size); dummy.updateMatrix(); fragments.setMatrixAt(i, dummy.matrix);
|
|
153
|
+
}); fragments.instanceMatrix.needsUpdate = true;
|
|
154
|
+
});
|
|
155
|
+
const orbital = new Group(); orbital.name = 'containment-ring'; orbital.rotation.set(1.12, .35, -.2); scene.add(orbital);
|
|
156
|
+
const line = material(new MeshBasicMaterial({ color: '#d9a151', transparent: true, opacity: .26 }));
|
|
157
|
+
for (let i = 0; i < 8; i++) {
|
|
158
|
+
const ring = mesh(geometry(new TorusGeometry(1.46, .003, 4, 32, .43)), line, orbital); ring.rotation.z = i / 8 * Math.PI * 2;
|
|
159
|
+
}
|
|
160
|
+
updates.push(time => { orbital.rotation.z = -.2 + time * .055; });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const particleCount = bird ? 120 : 420, positions: number[] = [], phases: number[] = [], sizes: number[] = [];
|
|
164
|
+
for (let i = 0; i < particleCount; i++) {
|
|
165
|
+
const a = random() * Math.PI * 2, r = .85 + random() * 1.25;
|
|
166
|
+
positions.push(Math.cos(a) * r, random() * 2.9 - 1.45, Math.sin(a) * r * .55); phases.push(random() * Math.PI * 2); sizes.push(.8 + random() * 2.2);
|
|
167
|
+
}
|
|
168
|
+
const particleGeometry = geometry(new BufferGeometry()); particleGeometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
|
|
169
|
+
particleGeometry.setAttribute('phase', new Float32BufferAttribute(phases, 1)); particleGeometry.setAttribute('size', new Float32BufferAttribute(sizes, 1));
|
|
170
|
+
particleGeometry.computeBoundingSphere(); particleGeometry.boundingSphere!.radius += .3;
|
|
171
|
+
const particleMaterial = material(new ShaderMaterial({
|
|
172
|
+
transparent: true, depthWrite: false, blending: bird ? NormalBlending : AdditiveBlending,
|
|
173
|
+
uniforms: { time: { value: 0 }, pixelRatio: { value: 1 }, tint: { value: new Color(bird ? '#ad935d' : '#ffab39') } },
|
|
174
|
+
vertexShader: `attribute float phase; attribute float size; uniform float time; uniform float pixelRatio; varying float alpha;
|
|
175
|
+
void main(){vec3 p=position; p.x+=sin(time*.2+phase)*.08; p.y+=sin(time*.3+phase)*.14;
|
|
176
|
+
vec4 mv=modelViewMatrix*vec4(p,1.); gl_Position=projectionMatrix*mv; gl_PointSize=size*pixelRatio*6./-mv.z;
|
|
177
|
+
alpha=.35+.3*sin(time*.8+phase);}`,
|
|
178
|
+
fragmentShader: `uniform vec3 tint; varying float alpha; void main(){float r=length(gl_PointCoord-.5)*2.;
|
|
179
|
+
gl_FragColor=vec4(tint,(1.-smoothstep(.05,1.,r))*alpha);}`,
|
|
180
|
+
}));
|
|
181
|
+
const dust = new Points(particleGeometry, particleMaterial); dust.name = bird ? 'paper-motes' : 'crystal-embers'; scene.add(dust);
|
|
182
|
+
const shadow = new Mesh(geometry(new PlaneGeometry(3.4, .65)), material(new ShaderMaterial({
|
|
183
|
+
transparent: true, depthWrite: false,
|
|
184
|
+
vertexShader: 'varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',
|
|
185
|
+
fragmentShader: 'varying vec2 vUv;void main(){float a=(1.-smoothstep(.05,1.,length((vUv-.5)*2.)))*.17;gl_FragColor=vec4(.15,.23,.19,a);}',
|
|
186
|
+
}))); scene.add(shadow); shadow.position.set(.08, -1.52, -1);
|
|
187
|
+
let lastWidth = 0, lastHeight = 0, lastRatio = 0;
|
|
188
|
+
canvas.addEventListener('webglcontextlost', contextLost); canvas.addEventListener('webglcontextrestored', contextRestored);
|
|
189
|
+
return {
|
|
190
|
+
canvas,
|
|
191
|
+
draw(width, height, time) {
|
|
192
|
+
if (disposed || lost || width <= 0 || height <= 0) return false;
|
|
193
|
+
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
|
|
194
|
+
if (width !== lastWidth || height !== lastHeight || ratio !== lastRatio) {
|
|
195
|
+
renderer!.setPixelRatio(ratio); renderer!.setSize(width, height, false);
|
|
196
|
+
const scale = Math.min(width * (bird ? .175 : .28), Math.max(1, height - 76) * (bird ? .285 : .3));
|
|
197
|
+
camera.aspect = width / height; camera.position.set(0, 0, 6); camera.lookAt(0, 0, 0);
|
|
198
|
+
camera.fov = 2 * Math.atan(height / (2 * scale * 6)) * 180 / Math.PI; camera.updateProjectionMatrix();
|
|
199
|
+
camera.projectionMatrix.elements[8] = -.08; camera.projectionMatrix.elements[9] = -36 / height;
|
|
200
|
+
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
|
|
201
|
+
particleMaterial.uniforms.pixelRatio.value = ratio; lastWidth = width; lastHeight = height; lastRatio = ratio;
|
|
202
|
+
}
|
|
203
|
+
if (!bird) object.rotation.y = time * .11;
|
|
204
|
+
else {
|
|
205
|
+
const flight = paperBirdFlight(time);
|
|
206
|
+
shadow.position.x = .08 + flight.x * .7;
|
|
207
|
+
shadow.scale.setScalar(1 - (flight.y + .3) * .18);
|
|
208
|
+
}
|
|
209
|
+
updates.forEach(update => update(time)); particleMaterial.uniforms.time.value = time;
|
|
210
|
+
renderer!.render(scene, camera); return true;
|
|
211
|
+
},
|
|
212
|
+
dispose,
|
|
213
|
+
};
|
|
214
|
+
} catch (error) { dispose(); throw error; }
|
|
215
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type OrbitVector = { x: number; y: number; z: number };
|
|
2
|
+
export const orbitCameraDistance = 3.6;
|
|
3
|
+
const yaw = -.72, tilt = .22, roll = .34;
|
|
4
|
+
const ct = Math.cos(tilt), st = Math.sin(tilt), cy = Math.cos(yaw), sy = Math.sin(yaw), cr = Math.cos(roll), sr = Math.sin(roll);
|
|
5
|
+
|
|
6
|
+
/** Keep every renderer in the original artwork's frame, including on narrow screens. */
|
|
7
|
+
export function orbitFraming(width: number, height: number) {
|
|
8
|
+
return { scale: Math.min(width * .66, height * .39), centerX: width * .62, centerY: height * .57 };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Shared camera orientation for the original network and the strawberry ring. */
|
|
12
|
+
export function rotateOrbitPoint({ x, y, z }: OrbitVector): OrbitVector {
|
|
13
|
+
const yy = y * ct - z * st, zz = y * st + z * ct;
|
|
14
|
+
const xx = x * cy + zz * sy, depth = -x * sy + zz * cy;
|
|
15
|
+
return { x: xx * cr - yy * sr, y: xx * sr + yy * cr, z: depth };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function projectOrbitPoint(p: OrbitVector, scale: number, centerX: number, centerY: number) {
|
|
19
|
+
const perspective = orbitCameraDistance / (orbitCameraDistance - p.z);
|
|
20
|
+
return { x: centerX + p.x * scale * perspective, y: centerY + p.y * scale * perspective * 1.08, depth: p.z, perspective };
|
|
21
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { createOrbitDonut } from './orbitDonut';
|
|
2
|
+
import type { DonutArtwork } from './orbitDonutThree';
|
|
3
|
+
import { drawOrbitObject } from './orbitObjects';
|
|
4
|
+
import { orbitFraming, projectOrbitPoint, rotateOrbitPoint } from './orbitProjection';
|
|
5
|
+
|
|
6
|
+
type OrbitLinework = 'original' | 'paper' | 'technical' | 'donut';
|
|
7
|
+
type Point = { x: number; y: number; depth: number; perspective: number };
|
|
8
|
+
type Edge = { a: number; sweep: number; phase: number; waist: number; lift: number; color: number; speed: number };
|
|
9
|
+
type Layer =
|
|
10
|
+
| { kind: 'void'; points: Point[] }
|
|
11
|
+
| { kind: 'line'; p: Point; q: Point; color: number; distance: number }
|
|
12
|
+
| { kind: 'particle'; p: Point; color: number }
|
|
13
|
+
| { kind: 'spike'; p: Point; q: Point; i: number; pulse: number };
|
|
14
|
+
type CanvasScene = { el: HTMLCanvasElement; ctx: CanvasRenderingContext2D; w: number; h: number };
|
|
15
|
+
const originalPalette = {
|
|
16
|
+
'line-primary': '#68b2f4', 'line-secondary': '#dfbb5c', 'line-tertiary': '#48b6b3',
|
|
17
|
+
'particle-primary': '#51b7f6', 'particle-secondary': '#e5b94d', 'particle-tertiary': '#2ec7a1',
|
|
18
|
+
'spike-near': '#d5e0f1', 'spike-far': '#b57635', 'trail-near': '#addaff', 'trail-far': '#e1a147',
|
|
19
|
+
spark: '#f2f7ff', void: '#010205', highlight: '#ff658c', 'highlight-glow': '#f25380',
|
|
20
|
+
};
|
|
21
|
+
function transparentColor(color: string) {
|
|
22
|
+
// Canvas serializes sRGB as hex/rgb and other color spaces as functions.
|
|
23
|
+
// Keep the RGB components at alpha zero: Canvas gradients do not premultiply interpolation.
|
|
24
|
+
if (color.startsWith('#')) return `${color.slice(0, 7)}00`;
|
|
25
|
+
if (color.startsWith('rgb')) {
|
|
26
|
+
const channels = color.slice(color.indexOf('(') + 1, -1).trim().split(/[\s,/]+/).slice(0, 3);
|
|
27
|
+
return `rgba(${channels.join(',')},0)`;
|
|
28
|
+
}
|
|
29
|
+
return color.replace(/(?:\s*\/[^()]*)?\)$/, ' / 0)');
|
|
30
|
+
}
|
|
31
|
+
export type OrbitState = 'playing' | 'paused' | 'reduced' | 'compact' | 'hidden' | 'offscreen' | 'empty' | 'unavailable';
|
|
32
|
+
export interface OrbitRenderer { setPaused(value: boolean): void; setReducedMotion(value: boolean): void; dispose(): void }
|
|
33
|
+
|
|
34
|
+
/** The seeded artwork is stable across mounts; only elapsed visible time advances it. */
|
|
35
|
+
export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitState) => void): OrbitRenderer {
|
|
36
|
+
let ctx: CanvasRenderingContext2D | null = null;
|
|
37
|
+
try { ctx = canvas.getContext('2d'); } catch { /* Canvas may be disabled by the host. */ }
|
|
38
|
+
if (!ctx) {
|
|
39
|
+
onState('unavailable');
|
|
40
|
+
return { setPaused() {}, setReducedMotion() {}, dispose() {} };
|
|
41
|
+
}
|
|
42
|
+
const TAU = Math.PI * 2;
|
|
43
|
+
let linework: OrbitLinework = 'original';
|
|
44
|
+
let drawDonut: ReturnType<typeof createOrbitDonut> | undefined;
|
|
45
|
+
type ObjectStyle = Exclude<OrbitLinework, 'original'>;
|
|
46
|
+
const artworks = new Map<ObjectStyle, DonutArtwork>();
|
|
47
|
+
const loading = new Set<ObjectStyle>(), failed = new Set<ObjectStyle>();
|
|
48
|
+
const palette = { ...originalPalette }, fadeColors = { near: '', far: '' };
|
|
49
|
+
function readPalette() {
|
|
50
|
+
const style = getComputedStyle(canvas), previousFill = ctx!.fillStyle;
|
|
51
|
+
const recipe = style.getPropertyValue('--rhine-orbit-linework').trim();
|
|
52
|
+
linework = recipe === 'paper' || recipe === 'technical' || recipe === 'donut' ? recipe : 'original';
|
|
53
|
+
for (const key of Object.keys(originalPalette) as (keyof typeof originalPalette)[]) {
|
|
54
|
+
// Let Canvas resolve CSS colors, including color-mix() and non-RGB host tokens.
|
|
55
|
+
// An invalid value leaves fillStyle unchanged, so each entry starts at its fallback.
|
|
56
|
+
ctx!.fillStyle = originalPalette[key];
|
|
57
|
+
ctx!.fillStyle = style.getPropertyValue(`--rhine-orbit-${key}`).trim() || originalPalette[key];
|
|
58
|
+
palette[key] = ctx!.fillStyle as string;
|
|
59
|
+
}
|
|
60
|
+
fadeColors.near = transparentColor(palette['trail-near']);
|
|
61
|
+
fadeColors.far = transparentColor(palette['trail-far']);
|
|
62
|
+
ctx!.fillStyle = previousFill;
|
|
63
|
+
}
|
|
64
|
+
let seed = 1823;
|
|
65
|
+
function rand() {
|
|
66
|
+
seed = seed * 1664525 + 1013904223 >>> 0;
|
|
67
|
+
return seed / 4294967296;
|
|
68
|
+
}
|
|
69
|
+
const nodes = Array.from({ length: 420 }, (_, i) => ({ a: i / 420 * TAU, r: 1.025 + rand() * 0.07, z: (rand() - 0.5) * 0.34, length: 0.035 + rand() * 0.19, color: i % 7 === 0 ? 1 : i % 11 === 0 ? 2 : 3 }));
|
|
70
|
+
const emissions = nodes.map(() => ({ phase: rand(), rate: 0.28 + rand() * 0.3 }));
|
|
71
|
+
const edges = Array.from({ length: 185 }, () => ({ a: rand() * TAU, sweep: 2.1 + rand() * 3.8, phase: rand() * TAU, waist: 0.5 + rand() * 0.32, lift: (rand() - 0.3) * 0.6, color: rand() < 0.32 ? 1 : rand() < 0.1 ? 2 : 0, speed: 0.026 + rand() * 0.024 }));
|
|
72
|
+
function edgePoint(e: Edge, t: number) {
|
|
73
|
+
const a = e.a + t * e.sweep, envelope = Math.sin(Math.PI * t);
|
|
74
|
+
return { a, r: 1.035 - (1.035 - e.waist) * Math.pow(envelope, 0.72), z: e.lift * envelope + 0.06 * Math.sin(e.phase + t * TAU) * envelope };
|
|
75
|
+
}
|
|
76
|
+
const paths = edges.map((e) => Array.from({ length: 81 }, (_, i) => edgePoint(e, i / 80)));
|
|
77
|
+
const canvases = { network: { el: canvas, ctx, w: 0, h: 0 } };
|
|
78
|
+
let framing = orbitFraming(0, 0);
|
|
79
|
+
let time = 0, orbit = 0, projected: Point[] = [], last: number | null = null, frame = 0;
|
|
80
|
+
let paused = false, manualReduced = false, disposed = false, visible = true;
|
|
81
|
+
let previousState: OrbitState | undefined;
|
|
82
|
+
function resize() {
|
|
83
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
84
|
+
// Layout CSS pixels exclude CSS zoom; visual scaling must not change stroke proportions.
|
|
85
|
+
const c = canvases.network;
|
|
86
|
+
c.w = c.el.clientWidth;
|
|
87
|
+
c.h = c.el.clientHeight;
|
|
88
|
+
framing = orbitFraming(c.w, c.h);
|
|
89
|
+
c.el.width = Math.round(c.w * dpr);
|
|
90
|
+
c.el.height = Math.round(c.h * dpr);
|
|
91
|
+
c.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
92
|
+
draw();
|
|
93
|
+
}
|
|
94
|
+
function line(ctx2: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, color: string | CanvasGradient, width = 1, alpha = 1) {
|
|
95
|
+
ctx2.globalAlpha = alpha;
|
|
96
|
+
ctx2.strokeStyle = color;
|
|
97
|
+
ctx2.lineWidth = width;
|
|
98
|
+
ctx2.beginPath();
|
|
99
|
+
ctx2.moveTo(x1, y1);
|
|
100
|
+
ctx2.lineTo(x2, y2);
|
|
101
|
+
ctx2.stroke();
|
|
102
|
+
ctx2.globalAlpha = 1;
|
|
103
|
+
}
|
|
104
|
+
function dot(ctx2: CanvasRenderingContext2D, x: number, y: number, r: number, color: string | CanvasGradient, alpha = 1) {
|
|
105
|
+
ctx2.globalAlpha = alpha;
|
|
106
|
+
ctx2.fillStyle = color;
|
|
107
|
+
ctx2.beginPath();
|
|
108
|
+
ctx2.arc(x, y, r, 0, TAU);
|
|
109
|
+
ctx2.fill();
|
|
110
|
+
ctx2.globalAlpha = 1;
|
|
111
|
+
}
|
|
112
|
+
function marker(x: number, y: number, radius: number, color: string, alpha = 1) {
|
|
113
|
+
if (linework === 'original') { dot(ctx!, x, y, radius, color, alpha); return; }
|
|
114
|
+
ctx!.globalAlpha = alpha;
|
|
115
|
+
if (linework === 'technical') {
|
|
116
|
+
ctx!.fillStyle = color;
|
|
117
|
+
ctx!.fillRect(x - radius, y - radius, radius * 2, radius * 2);
|
|
118
|
+
} else {
|
|
119
|
+
ctx!.beginPath(); ctx!.arc(x, y, radius, 0, TAU);
|
|
120
|
+
ctx!.fillStyle = palette.void;
|
|
121
|
+
ctx!.fill();
|
|
122
|
+
ctx!.strokeStyle = color;
|
|
123
|
+
ctx!.lineWidth = .8;
|
|
124
|
+
ctx!.stroke();
|
|
125
|
+
}
|
|
126
|
+
ctx!.globalAlpha = 1;
|
|
127
|
+
}
|
|
128
|
+
function clear(c: CanvasScene) {
|
|
129
|
+
c.ctx.clearRect(0, 0, c.w, c.h);
|
|
130
|
+
}
|
|
131
|
+
function project(a: number, r = 1, z = 0) {
|
|
132
|
+
const { scale, centerX, centerY } = framing;
|
|
133
|
+
return projectOrbitPoint(rotateOrbitPoint({ x: Math.cos(a) * r, y: Math.sin(a) * r, z }), scale, centerX, centerY);
|
|
134
|
+
}
|
|
135
|
+
function network() {
|
|
136
|
+
const c = canvases.network, { ctx: ctx2 } = c;
|
|
137
|
+
clear(c);
|
|
138
|
+
const original = linework === 'original', paper = linework === 'paper', technical = linework === 'technical';
|
|
139
|
+
ctx2.lineCap = original || technical ? 'butt' : 'round';
|
|
140
|
+
ctx2.lineJoin = original || technical ? 'miter' : 'round';
|
|
141
|
+
ctx2.setLineDash([]);
|
|
142
|
+
ctx2.lineDashOffset = 0;
|
|
143
|
+
const buckets: Layer[][] = Array.from({ length: 80 }, () => []), offset = orbit;
|
|
144
|
+
function add(item: Layer, depth: number) {
|
|
145
|
+
buckets[Math.max(0, Math.min(79, Math.floor((depth + 1.5) / 3 * 80)))].push(item);
|
|
146
|
+
}
|
|
147
|
+
const horizon = Array.from({ length: 101 }, (_, i) => project(i / 100 * TAU, 0.46, -0.06));
|
|
148
|
+
add({ kind: "void", points: horizon }, -0.04);
|
|
149
|
+
edges.forEach((e, index) => {
|
|
150
|
+
const pts = paths[index].map((p) => project(p.a + offset, p.r, p.z));
|
|
151
|
+
let distance = 0;
|
|
152
|
+
for (let i = 1; i < pts.length; i++) {
|
|
153
|
+
add({ kind: "line", p: pts[i - 1], q: pts[i], color: e.color, distance }, (pts[i - 1].depth + pts[i].depth) / 2 + 7e-3);
|
|
154
|
+
distance += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
|
155
|
+
}
|
|
156
|
+
if (index % 3 === 0) {
|
|
157
|
+
const t = (time * e.speed + e.phase / TAU) % 1, p3 = edgePoint(e, t), p = project(p3.a + offset, p3.r, p3.z);
|
|
158
|
+
add({ kind: "particle", p, color: e.color }, p.depth + 0.012);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
projected = nodes.map((n) => project(n.a + offset, n.r, n.z));
|
|
162
|
+
nodes.forEach((n, i) => {
|
|
163
|
+
const emission = emissions[i], pulse = (time * emission.rate + emission.phase) % 1, breath = 1 + 0.18 * Math.sin(time * 1.7 + n.a * 5), p = projected[i], q = project(n.a + offset, n.r + n.length * breath, n.z * 1.1);
|
|
164
|
+
add({ kind: "spike", p, q, i, pulse }, (p.depth + q.depth) / 2);
|
|
165
|
+
});
|
|
166
|
+
const lineColors = [palette['line-primary'], palette['line-secondary'], palette['line-tertiary']];
|
|
167
|
+
const particleColors = [palette['particle-primary'], palette['particle-secondary'], palette['particle-tertiary']];
|
|
168
|
+
for (const bucket of buckets) for (const item of bucket) {
|
|
169
|
+
if (item.kind === "void") {
|
|
170
|
+
ctx2.beginPath();
|
|
171
|
+
item.points.forEach((p2, i) => i ? ctx2.lineTo(p2.x, p2.y) : ctx2.moveTo(p2.x, p2.y));
|
|
172
|
+
ctx2.closePath();
|
|
173
|
+
ctx2.fillStyle = palette.void;
|
|
174
|
+
ctx2.fill();
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const p = item.p, near = Math.max(0, Math.min(1, (p.depth + 1) / 2));
|
|
178
|
+
if (item.kind === "line") {
|
|
179
|
+
// Keep the same projected segments and depth order; dash phase follows the whole curve.
|
|
180
|
+
if (technical && item.color !== 1) {
|
|
181
|
+
ctx2.setLineDash([4, 5]); ctx2.lineDashOffset = -item.distance;
|
|
182
|
+
}
|
|
183
|
+
const width = original ? .35 + near * .65 : paper ? .45 + near * .65 : .55 + near * .95;
|
|
184
|
+
const alpha = original ? .07 + near * near * .57 : paper ? .05 + near * near * .48 : .08 + near * near * .55;
|
|
185
|
+
line(ctx2, p.x, p.y, item.q.x, item.q.y, lineColors[item.color], width, alpha);
|
|
186
|
+
if (technical) { ctx2.setLineDash([]); ctx2.lineDashOffset = 0; }
|
|
187
|
+
} else if (item.kind === "spike") {
|
|
188
|
+
const q = item.q, bright = near > 0.48, color = palette[bright ? 'trail-near' : 'trail-far'], dx = q.x - p.x, dy = q.y - p.y, len = Math.hypot(dx, dy) || 1, ux = dx / len, uy = dy / len;
|
|
189
|
+
if (technical) ctx2.setLineDash([2, 3]);
|
|
190
|
+
line(ctx2, p.x, p.y, q.x, q.y, palette[bright ? 'spike-near' : 'spike-far'], original ? .4 + near * .4 : paper ? .5 : .7, bright ? .16 + near * .35 : .12 + near * .3);
|
|
191
|
+
if (technical) ctx2.setLineDash([]);
|
|
192
|
+
const t = item.pulse, fade = Math.sin(Math.PI * t), alpha = fade * (0.25 + near * 0.75), x = p.x + dx * t, y = p.y + dy * t, tail = Math.max(0, t - 0.38), tx = p.x + dx * tail, ty = p.y + dy * tail;
|
|
193
|
+
if (original) {
|
|
194
|
+
const trail = ctx2.createLinearGradient(tx, ty, x + 1e-3, y + 1e-3);
|
|
195
|
+
trail.addColorStop(0, fadeColors[bright ? 'near' : 'far']);
|
|
196
|
+
trail.addColorStop(1, color);
|
|
197
|
+
line(ctx2, tx, ty, x, y, trail, .7 + near * .9, alpha);
|
|
198
|
+
} else {
|
|
199
|
+
if (technical) ctx2.setLineDash([2, 2]);
|
|
200
|
+
line(ctx2, tx, ty, x, y, color, paper ? .65 + near * .65 : 1 + near * .8, alpha);
|
|
201
|
+
if (technical) ctx2.setLineDash([]);
|
|
202
|
+
}
|
|
203
|
+
const size = (1.6 + near * 1.5) * q.perspective;
|
|
204
|
+
ctx2.globalAlpha = alpha;
|
|
205
|
+
ctx2.strokeStyle = color;
|
|
206
|
+
ctx2.lineWidth = original ? .65 + near * .5 : paper ? .8 : 1.1;
|
|
207
|
+
ctx2.beginPath();
|
|
208
|
+
ctx2.moveTo(x - ux * size - uy * size * 0.55, y - uy * size + ux * size * 0.55);
|
|
209
|
+
ctx2.lineTo(x, y);
|
|
210
|
+
ctx2.lineTo(x - ux * size + uy * size * 0.55, y - uy * size - ux * size * 0.55);
|
|
211
|
+
ctx2.stroke();
|
|
212
|
+
ctx2.globalAlpha = 1;
|
|
213
|
+
if (item.i % 3 === 0) {
|
|
214
|
+
if (original) {
|
|
215
|
+
const radius = (4 + near * 4) * q.perspective, glow = ctx2.createRadialGradient(x, y, 0, x, y, radius);
|
|
216
|
+
glow.addColorStop(0, color);
|
|
217
|
+
glow.addColorStop(1, fadeColors[bright ? 'near' : 'far']);
|
|
218
|
+
dot(ctx2, x, y, radius, glow, alpha * .45);
|
|
219
|
+
}
|
|
220
|
+
dot(ctx2, x, y, 0.65 + near * 0.45, palette.spark, alpha);
|
|
221
|
+
}
|
|
222
|
+
const flash = Math.pow(Math.max(0, 1 - Math.abs(t - 0.88) / 0.12), 2);
|
|
223
|
+
dot(ctx2, q.x, q.y, (0.55 + flash * 1.1) * q.perspective, color, 0.18 + near * 0.25 + flash * 0.35);
|
|
224
|
+
} else {
|
|
225
|
+
ctx2.shadowBlur = original ? 5 : 0;
|
|
226
|
+
ctx2.shadowColor = particleColors[item.color];
|
|
227
|
+
marker(p.x, p.y, 1.1 * p.perspective, particleColors[item.color]);
|
|
228
|
+
ctx2.shadowBlur = 0;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (let i = 0; i < 8; i++) {
|
|
232
|
+
const e = edges[i * 19], v = edgePoint(e, 0.4), p = project(v.a + offset, v.r, v.z);
|
|
233
|
+
if (p.depth < -0.1) continue;
|
|
234
|
+
ctx2.shadowColor = palette['highlight-glow'];
|
|
235
|
+
ctx2.shadowBlur = original ? 10 : 0;
|
|
236
|
+
marker(p.x, p.y, 2.2 * p.perspective, palette.highlight);
|
|
237
|
+
ctx2.shadowBlur = 0;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
241
|
+
const compact = window.matchMedia?.('(max-width: 760px)');
|
|
242
|
+
function draw() {
|
|
243
|
+
const { w, h } = canvases.network;
|
|
244
|
+
if (!w || !h) return;
|
|
245
|
+
let rendered = false;
|
|
246
|
+
if (linework !== 'original') {
|
|
247
|
+
const kind = linework;
|
|
248
|
+
if (!artworks.has(kind) && !loading.has(kind) && !failed.has(kind) && window.WebGL2RenderingContext) {
|
|
249
|
+
loading.add(kind);
|
|
250
|
+
const load = kind === 'donut'
|
|
251
|
+
? import('./orbitDonutThree').then(module => () => module.createDonutArtwork(sync))
|
|
252
|
+
: import('./orbitObjectsThree').then(module => () => module.createOrbitObject(kind, sync));
|
|
253
|
+
load.then(create => {
|
|
254
|
+
loading.delete(kind);
|
|
255
|
+
if (disposed || linework !== kind) return;
|
|
256
|
+
const artwork = create();
|
|
257
|
+
artworks.set(kind, artwork);
|
|
258
|
+
canvas.after(artwork.canvas);
|
|
259
|
+
sync();
|
|
260
|
+
}).catch(() => { loading.delete(kind); failed.add(kind); });
|
|
261
|
+
}
|
|
262
|
+
try { rendered = artworks.get(kind)?.draw(w, h, time) ?? false; }
|
|
263
|
+
catch { artworks.get(kind)?.dispose(); artworks.delete(kind); failed.add(kind); }
|
|
264
|
+
if (!rendered) {
|
|
265
|
+
if (kind === 'donut') (drawDonut ??= createOrbitDonut())(ctx!, w, h, time, palette['line-primary']);
|
|
266
|
+
else drawOrbitObject(ctx!, w, h, time, kind);
|
|
267
|
+
}
|
|
268
|
+
} else network();
|
|
269
|
+
// Keep the measured 2D canvas in layout while the optional WebGL painter covers it.
|
|
270
|
+
canvas.toggleAttribute('data-orbit-covered', rendered);
|
|
271
|
+
for (const [kind, artwork] of artworks) artwork.canvas.hidden = kind !== linework || !rendered;
|
|
272
|
+
}
|
|
273
|
+
function state(): OrbitState {
|
|
274
|
+
if (manualReduced || reduced?.matches || canvas.closest('[data-rhine-motion="reduced"]')) return 'reduced';
|
|
275
|
+
if (compact?.matches && linework === 'original') return 'compact';
|
|
276
|
+
if (document.hidden) return 'hidden';
|
|
277
|
+
if (!visible) return 'offscreen';
|
|
278
|
+
if (canvases.network.w <= 0 || canvases.network.h <= 0) return 'empty';
|
|
279
|
+
return paused ? 'paused' : 'playing';
|
|
280
|
+
}
|
|
281
|
+
function animate(now: number) {
|
|
282
|
+
frame = 0;
|
|
283
|
+
if (disposed) return;
|
|
284
|
+
// A frame can observe a media change before its change event is delivered.
|
|
285
|
+
if (state() !== 'playing') { sync(); return; }
|
|
286
|
+
const dt = last === null ? 0 : Math.min((now - last) / 1e3, 0.1);
|
|
287
|
+
last = now;
|
|
288
|
+
time += dt;
|
|
289
|
+
orbit += dt * 0.028;
|
|
290
|
+
draw();
|
|
291
|
+
frame = requestAnimationFrame(animate);
|
|
292
|
+
}
|
|
293
|
+
function sync() {
|
|
294
|
+
if (disposed) return;
|
|
295
|
+
cancelAnimationFrame(frame);
|
|
296
|
+
frame = 0;
|
|
297
|
+
last = null;
|
|
298
|
+
readPalette();
|
|
299
|
+
resize();
|
|
300
|
+
const next = state();
|
|
301
|
+
if (next !== previousState) { previousState = next; onState(next); }
|
|
302
|
+
if (next === 'playing') frame = requestAnimationFrame(animate);
|
|
303
|
+
}
|
|
304
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(sync);
|
|
305
|
+
observer?.observe(canvas);
|
|
306
|
+
const intersection = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver(entries => {
|
|
307
|
+
visible = entries.some(entry => entry.target === canvas && entry.isIntersecting);
|
|
308
|
+
sync();
|
|
309
|
+
});
|
|
310
|
+
intersection?.observe(canvas);
|
|
311
|
+
const appearance = new MutationObserver(sync);
|
|
312
|
+
// CSS-only roots can switch classes or custom properties without a ThemeProvider.
|
|
313
|
+
// Excluding canvas sizing and React state attributes prevents redraw loops.
|
|
314
|
+
for (let ancestor: HTMLElement | null = canvas; ancestor; ancestor = ancestor.parentElement) {
|
|
315
|
+
appearance.observe(ancestor, { attributes: true, attributeFilter: ['data-rhine-motion', 'data-rhine-theme', 'data-rhine-orbit-appearance', 'class', 'style'] });
|
|
316
|
+
}
|
|
317
|
+
reduced?.addEventListener?.('change', sync);
|
|
318
|
+
compact?.addEventListener?.('change', sync);
|
|
319
|
+
window.addEventListener('resize', sync);
|
|
320
|
+
document.addEventListener("visibilitychange", sync);
|
|
321
|
+
sync();
|
|
322
|
+
return {
|
|
323
|
+
setPaused(value) {
|
|
324
|
+
if (paused === value) return;
|
|
325
|
+
paused = value;
|
|
326
|
+
sync();
|
|
327
|
+
},
|
|
328
|
+
setReducedMotion(value) {
|
|
329
|
+
if (manualReduced === value) return;
|
|
330
|
+
manualReduced = value;
|
|
331
|
+
sync();
|
|
332
|
+
},
|
|
333
|
+
dispose() {
|
|
334
|
+
disposed = true;
|
|
335
|
+
for (const artwork of artworks.values()) artwork.dispose();
|
|
336
|
+
artworks.clear(); canvas.removeAttribute('data-orbit-covered');
|
|
337
|
+
cancelAnimationFrame(frame);
|
|
338
|
+
observer?.disconnect();
|
|
339
|
+
intersection?.disconnect();
|
|
340
|
+
appearance.disconnect();
|
|
341
|
+
reduced?.removeEventListener?.('change', sync);
|
|
342
|
+
compact?.removeEventListener?.('change', sync);
|
|
343
|
+
window.removeEventListener('resize', sync);
|
|
344
|
+
document.removeEventListener("visibilitychange", sync);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
package/components/styles.css
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
@import './timeline.css';
|
|
6
6
|
@import './extensions.css';
|
|
7
7
|
@import './background.css';
|
|
8
|
+
@import './orbit.css';
|
|
9
|
+
@import './neutron.css';
|
|
8
10
|
@import './charts.css';
|
|
9
11
|
|
|
10
12
|
.rhine-theme { color:var(--rhine-ink); background:var(--rhine-bg); font:var(--rhine-text-body)/var(--rhine-leading-body) var(--rhine-font); font-synthesis:none; }
|
|
@@ -21,7 +23,8 @@
|
|
|
21
23
|
.rhine-button--secondary:hover:not(:disabled):not([aria-disabled=true]),.rhine-button--quiet:hover:not(:disabled):not([aria-disabled=true]) { background:var(--rhine-selected); }
|
|
22
24
|
.rhine-button--danger { background:var(--rhine-error); border-color:var(--rhine-error); }
|
|
23
25
|
.rhine-button--danger:hover:not(:disabled):not([aria-disabled=true]) { background:#81342e; border-color:#81342e; }
|
|
24
|
-
.rhine-button:disabled { background:var(--rhine-selected); border-color:var(--rhine-line); color:var(--rhine-muted); cursor:default; }
|
|
26
|
+
.rhine-button:is(:disabled,[data-disabled=true],[aria-disabled=true]:not([aria-busy=true])) { background:var(--rhine-selected); border-color:var(--rhine-line); color:var(--rhine-muted); cursor:default; }
|
|
27
|
+
.rhine-button-link { text-decoration:none; text-align:center; }
|
|
25
28
|
.rhine-button[aria-busy=true] { cursor:wait; }
|
|
26
29
|
.rhine-button__ghost { visibility:hidden; }
|
|
27
30
|
.rhine-button__busy { position:absolute; inset:0; display:grid; place-items:center; }
|
|
@@ -53,12 +53,13 @@
|
|
|
53
53
|
.rhine-theme[data-rhine-theme="neo-brutalism"] :is(.rhine-button--secondary, .rhine-button--quiet):hover:not(:disabled):not([aria-disabled=true]) { background: var(--rhine-selected); }
|
|
54
54
|
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button--danger:hover:not(:disabled):not([aria-disabled=true]) { background: #81262d; }
|
|
55
55
|
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button:active:not(:disabled):not([aria-disabled=true]) { transform: translate(3px, 3px); box-shadow: none; }
|
|
56
|
-
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button:disabled { background: #e1e5e8; color: var(--rhine-muted); border-color: #89929a; box-shadow: none; }
|
|
56
|
+
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button:is(:disabled,[data-disabled=true],[aria-disabled=true]:not([aria-busy=true])) { background: #e1e5e8; color: var(--rhine-muted); border-color: #89929a; box-shadow: none; }
|
|
57
57
|
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-button[aria-busy=true] { box-shadow: none; }
|
|
58
58
|
.rhine-theme[data-rhine-theme="neo-brutalism"] :is(button, input, select, textarea, summary, a, [tabindex]):focus-visible { outline: 3px solid var(--rhine-ink); outline-offset: 4px; }
|
|
59
59
|
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-tone-success { --rhine-tone-bg: var(--rhine-tag-green); }
|
|
60
60
|
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-tone-neutral { --rhine-tone-bg: #e7ebee; }
|
|
61
|
-
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-badge { border:
|
|
61
|
+
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-badge { border: 1.5px solid var(--rhine-ink); border-radius: 8px; padding: 3px 9px; font-weight: 700; }
|
|
62
|
+
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-badge.rhine-tone-neutral { color: var(--rhine-ink); }
|
|
62
63
|
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-type-tag { display: inline-flex; padding: 4px 10px; border: 1.5px solid var(--rhine-ink); border-radius: 8px; color: var(--rhine-ink); font-size: 12px; line-height: 20px; font-weight: 700; }
|
|
63
64
|
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-type-tag[data-color=green] { background: var(--rhine-tag-green); }
|
|
64
65
|
.rhine-theme[data-rhine-theme="neo-brutalism"] .rhine-type-tag[data-color=orange] { background: var(--rhine-tag-orange); }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.rhine-theme-picker { min-width:0; margin:0; padding:0; border:0; }
|
|
2
|
+
.rhine-theme-picker>legend { padding:0; margin-bottom:var(--rhine-space-3); font-weight:var(--rhine-weight-heading); }
|
|
3
|
+
.rhine-theme-picker__options { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr)); gap:var(--rhine-space-4); }
|
|
4
|
+
.rhine-theme-option { position:relative; display:grid; align-content:start; gap:var(--rhine-space-3); min-width:0; min-height:44px; padding:var(--rhine-space-3); border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-card); background:var(--rhine-surface); color:var(--rhine-ink); cursor:pointer; }
|
|
5
|
+
.rhine-theme-option:has(input:checked) { border-color:var(--rhine-green); box-shadow:inset 0 0 0 2px var(--rhine-green); }
|
|
6
|
+
.rhine-theme-option:has(input:focus-visible) { outline:3px solid var(--rhine-green); outline-offset:3px; }
|
|
7
|
+
.rhine-theme-option:has(input:disabled) { opacity:.55; cursor:not-allowed; }
|
|
8
|
+
.rhine-theme-option:hover:not(:has(input:disabled)) { background:var(--rhine-selected); }
|
|
9
|
+
.rhine-theme-option__copy { display:grid; gap:var(--rhine-space-2); min-width:0; overflow-wrap:anywhere; }
|
|
10
|
+
.rhine-theme-option__description { font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); color:var(--rhine-muted); }
|
|
11
|
+
.rhine-theme-option__status { font-size:var(--rhine-text-caption); min-height:24px; }
|
|
12
|
+
.rhine-theme-preview { position:relative; display:grid; place-items:center; aspect-ratio:8/5; min-width:0; overflow:hidden; border:1px solid var(--rhine-line); border-radius:var(--rhine-radius-control); background:var(--rhine-bg); color:var(--rhine-muted); font-size:var(--rhine-text-caption); }
|
|
13
|
+
.rhine-theme-preview img { display:block; width:100%; height:100%; object-fit:contain; }
|
|
14
|
+
.rhine-theme-preview__loading { position:absolute; inset:0; display:grid; place-items:center; background:var(--rhine-bg); }
|
|
15
|
+
.rhine-theme[data-rhine-theme=neo-brutalism] .rhine-theme-option { border:2px solid var(--rhine-ink); box-shadow:var(--rhine-hard-shadow); }
|
|
16
|
+
.rhine-theme[data-rhine-theme=neo-brutalism] .rhine-theme-option:has(input:checked) { box-shadow:inset 0 0 0 2px var(--rhine-green),var(--rhine-hard-shadow); }
|
|
17
|
+
@media(forced-colors:active) { .rhine-theme-option:has(input:checked) { outline:3px solid Highlight; } }
|