@linhey/rhine-design 0.6.0 → 0.9.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 +19 -8
- package/components/Actions.tsx +18 -1
- package/components/AdvancedForms.tsx +38 -8
- package/components/AppShell.tsx +46 -17
- package/components/ListFilterBar.tsx +104 -0
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +11 -3
- package/components/Overlays.tsx +37 -8
- package/components/README.md +28 -3
- 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 +12 -5
- package/components/list-filter-bar.css +33 -0
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +100 -1
- 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 +163 -46
- package/components/styles.css +3 -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/BACKGROUND_FILTER_VERIFICATION.md +12 -0
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
- package/docs/DATE_RANGE_FILTER.md +60 -0
- package/docs/DESIGN_SYSTEM.md +11 -0
- package/docs/FLOATING_OVERLAYS.md +15 -0
- package/docs/INTERACTION_PATTERNS.md +10 -2
- package/docs/LIST_FILTER_BAR.md +73 -0
- package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
- package/docs/ORBIT_BACKDROP.md +61 -44
- 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-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/BackgroundFiltersFixture.tsx +28 -0
- 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/ListFilterBarGallery.tsx +170 -0
- package/preview/MonitoringGallery.tsx +2 -0
- package/preview/NeutronStarGallery.tsx +56 -0
- package/preview/OrbitGallery.tsx +49 -32
- 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 -3
- package/preview/preview.css +23 -0
- package/preview/theme-sample.html +1 -0
- package/scripts/check-background-filters.js +58 -0
- package/scripts/check-background-motion.js +169 -0
- package/scripts/check-filter-native-date.js +47 -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 +13 -2
- package/skills/rhine-design/references/TASK_MAP.md +8 -2
- 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/DateNativeInput.test.tsx +147 -0
- package/tests/DateRangeFilterGallery.test.tsx +85 -0
- package/tests/ListFilterBar.test.tsx +198 -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 +273 -9
- 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
|
+
}
|
|
@@ -1,11 +1,33 @@
|
|
|
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';
|
|
1
7
|
type Point = { x: number; y: number; depth: number; perspective: number };
|
|
2
8
|
type Edge = { a: number; sweep: number; phase: number; waist: number; lift: number; color: number; speed: number };
|
|
3
9
|
type Layer =
|
|
4
10
|
| { kind: 'void'; points: Point[] }
|
|
5
|
-
| { kind: 'line'; p: Point; q: Point; color: number }
|
|
11
|
+
| { kind: 'line'; p: Point; q: Point; color: number; distance: number }
|
|
6
12
|
| { kind: 'particle'; p: Point; color: number }
|
|
7
13
|
| { kind: 'spike'; p: Point; q: Point; i: number; pulse: number };
|
|
8
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
|
+
}
|
|
9
31
|
export type OrbitState = 'playing' | 'paused' | 'reduced' | 'compact' | 'hidden' | 'offscreen' | 'empty' | 'unavailable';
|
|
10
32
|
export interface OrbitRenderer { setPaused(value: boolean): void; setReducedMotion(value: boolean): void; dispose(): void }
|
|
11
33
|
|
|
@@ -17,7 +39,28 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
|
|
|
17
39
|
onState('unavailable');
|
|
18
40
|
return { setPaused() {}, setReducedMotion() {}, dispose() {} };
|
|
19
41
|
}
|
|
20
|
-
const TAU = Math.PI * 2
|
|
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
|
+
}
|
|
21
64
|
let seed = 1823;
|
|
22
65
|
function rand() {
|
|
23
66
|
seed = seed * 1664525 + 1013904223 >>> 0;
|
|
@@ -32,8 +75,8 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
|
|
|
32
75
|
}
|
|
33
76
|
const paths = edges.map((e) => Array.from({ length: 81 }, (_, i) => edgePoint(e, i / 80)));
|
|
34
77
|
const canvases = { network: { el: canvas, ctx, w: 0, h: 0 } };
|
|
78
|
+
let framing = orbitFraming(0, 0);
|
|
35
79
|
let time = 0, orbit = 0, projected: Point[] = [], last: number | null = null, frame = 0;
|
|
36
|
-
const yaw = -0.72, tilt = 0.22;
|
|
37
80
|
let paused = false, manualReduced = false, disposed = false, visible = true;
|
|
38
81
|
let previousState: OrbitState | undefined;
|
|
39
82
|
function resize() {
|
|
@@ -42,38 +85,61 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
|
|
|
42
85
|
const c = canvases.network;
|
|
43
86
|
c.w = c.el.clientWidth;
|
|
44
87
|
c.h = c.el.clientHeight;
|
|
88
|
+
framing = orbitFraming(c.w, c.h);
|
|
45
89
|
c.el.width = Math.round(c.w * dpr);
|
|
46
90
|
c.el.height = Math.round(c.h * dpr);
|
|
47
91
|
c.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
48
92
|
draw();
|
|
49
93
|
}
|
|
50
|
-
function line(ctx2: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, color: string | CanvasGradient, width = 1) {
|
|
94
|
+
function line(ctx2: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, color: string | CanvasGradient, width = 1, alpha = 1) {
|
|
95
|
+
ctx2.globalAlpha = alpha;
|
|
51
96
|
ctx2.strokeStyle = color;
|
|
52
97
|
ctx2.lineWidth = width;
|
|
53
98
|
ctx2.beginPath();
|
|
54
99
|
ctx2.moveTo(x1, y1);
|
|
55
100
|
ctx2.lineTo(x2, y2);
|
|
56
101
|
ctx2.stroke();
|
|
102
|
+
ctx2.globalAlpha = 1;
|
|
57
103
|
}
|
|
58
|
-
function dot(ctx2: CanvasRenderingContext2D, x: number, y: number, r: number, color: string | CanvasGradient) {
|
|
104
|
+
function dot(ctx2: CanvasRenderingContext2D, x: number, y: number, r: number, color: string | CanvasGradient, alpha = 1) {
|
|
105
|
+
ctx2.globalAlpha = alpha;
|
|
59
106
|
ctx2.fillStyle = color;
|
|
60
107
|
ctx2.beginPath();
|
|
61
108
|
ctx2.arc(x, y, r, 0, TAU);
|
|
62
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;
|
|
63
127
|
}
|
|
64
128
|
function clear(c: CanvasScene) {
|
|
65
129
|
c.ctx.clearRect(0, 0, c.w, c.h);
|
|
66
130
|
}
|
|
67
131
|
function project(a: number, r = 1, z = 0) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const xx = x * Math.cos(yaw) + zz * Math.sin(yaw), depth = -x * Math.sin(yaw) + zz * Math.cos(yaw);
|
|
71
|
-
const spin = 0.34, px = xx * Math.cos(spin) - yy * Math.sin(spin), py = xx * Math.sin(spin) + yy * Math.cos(spin), perspective = 3.6 / (3.6 - depth);
|
|
72
|
-
return { x: c.w * 0.62 + px * scale * perspective, y: c.h * 0.57 + py * scale * perspective * 1.08, depth, perspective };
|
|
132
|
+
const { scale, centerX, centerY } = framing;
|
|
133
|
+
return projectOrbitPoint(rotateOrbitPoint({ x: Math.cos(a) * r, y: Math.sin(a) * r, z }), scale, centerX, centerY);
|
|
73
134
|
}
|
|
74
135
|
function network() {
|
|
75
136
|
const c = canvases.network, { ctx: ctx2 } = c;
|
|
76
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;
|
|
77
143
|
const buckets: Layer[][] = Array.from({ length: 80 }, () => []), offset = orbit;
|
|
78
144
|
function add(item: Layer, depth: number) {
|
|
79
145
|
buckets[Math.max(0, Math.min(79, Math.floor((depth + 1.5) / 3 * 80)))].push(item);
|
|
@@ -82,7 +148,11 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
|
|
|
82
148
|
add({ kind: "void", points: horizon }, -0.04);
|
|
83
149
|
edges.forEach((e, index) => {
|
|
84
150
|
const pts = paths[index].map((p) => project(p.a + offset, p.r, p.z));
|
|
85
|
-
|
|
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
|
+
}
|
|
86
156
|
if (index % 3 === 0) {
|
|
87
157
|
const t = (time * e.speed + e.phase / TAU) % 1, p3 = edgePoint(e, t), p = project(p3.a + offset, p3.r, p3.z);
|
|
88
158
|
add({ kind: "particle", p, color: e.color }, p.depth + 0.012);
|
|
@@ -93,73 +163,116 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
|
|
|
93
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);
|
|
94
164
|
add({ kind: "spike", p, q, i, pulse }, (p.depth + q.depth) / 2);
|
|
95
165
|
});
|
|
96
|
-
const
|
|
166
|
+
const lineColors = [palette['line-primary'], palette['line-secondary'], palette['line-tertiary']];
|
|
167
|
+
const particleColors = [palette['particle-primary'], palette['particle-secondary'], palette['particle-tertiary']];
|
|
97
168
|
for (const bucket of buckets) for (const item of bucket) {
|
|
98
169
|
if (item.kind === "void") {
|
|
99
170
|
ctx2.beginPath();
|
|
100
171
|
item.points.forEach((p2, i) => i ? ctx2.lineTo(p2.x, p2.y) : ctx2.moveTo(p2.x, p2.y));
|
|
101
172
|
ctx2.closePath();
|
|
102
|
-
ctx2.fillStyle =
|
|
173
|
+
ctx2.fillStyle = palette.void;
|
|
103
174
|
ctx2.fill();
|
|
104
175
|
continue;
|
|
105
176
|
}
|
|
106
177
|
const p = item.p, near = Math.max(0, Math.min(1, (p.depth + 1) / 2));
|
|
107
178
|
if (item.kind === "line") {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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; }
|
|
114
187
|
} else if (item.kind === "spike") {
|
|
115
|
-
const q = item.q, bright = near > 0.48,
|
|
116
|
-
|
|
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([]);
|
|
117
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;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|
|
122
203
|
const size = (1.6 + near * 1.5) * q.perspective;
|
|
123
|
-
ctx2.
|
|
124
|
-
ctx2.
|
|
204
|
+
ctx2.globalAlpha = alpha;
|
|
205
|
+
ctx2.strokeStyle = color;
|
|
206
|
+
ctx2.lineWidth = original ? .65 + near * .5 : paper ? .8 : 1.1;
|
|
125
207
|
ctx2.beginPath();
|
|
126
208
|
ctx2.moveTo(x - ux * size - uy * size * 0.55, y - uy * size + ux * size * 0.55);
|
|
127
209
|
ctx2.lineTo(x, y);
|
|
128
210
|
ctx2.lineTo(x - ux * size + uy * size * 0.55, y - uy * size - ux * size * 0.55);
|
|
129
211
|
ctx2.stroke();
|
|
212
|
+
ctx2.globalAlpha = 1;
|
|
130
213
|
if (item.i % 3 === 0) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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);
|
|
136
221
|
}
|
|
137
222
|
const flash = Math.pow(Math.max(0, 1 - Math.abs(t - 0.88) / 0.12), 2);
|
|
138
|
-
dot(ctx2, q.x, q.y, (0.55 + flash * 1.1) * q.perspective,
|
|
223
|
+
dot(ctx2, q.x, q.y, (0.55 + flash * 1.1) * q.perspective, color, 0.18 + near * 0.25 + flash * 0.35);
|
|
139
224
|
} else {
|
|
140
|
-
ctx2.shadowBlur = 5;
|
|
141
|
-
ctx2.shadowColor =
|
|
142
|
-
|
|
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]);
|
|
143
228
|
ctx2.shadowBlur = 0;
|
|
144
229
|
}
|
|
145
230
|
}
|
|
146
231
|
for (let i = 0; i < 8; i++) {
|
|
147
232
|
const e = edges[i * 19], v = edgePoint(e, 0.4), p = project(v.a + offset, v.r, v.z);
|
|
148
233
|
if (p.depth < -0.1) continue;
|
|
149
|
-
ctx2.shadowColor =
|
|
150
|
-
ctx2.shadowBlur = 10;
|
|
151
|
-
|
|
234
|
+
ctx2.shadowColor = palette['highlight-glow'];
|
|
235
|
+
ctx2.shadowBlur = original ? 10 : 0;
|
|
236
|
+
marker(p.x, p.y, 2.2 * p.perspective, palette.highlight);
|
|
152
237
|
ctx2.shadowBlur = 0;
|
|
153
238
|
}
|
|
154
239
|
}
|
|
155
240
|
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
156
241
|
const compact = window.matchMedia?.('(max-width: 760px)');
|
|
157
242
|
function draw() {
|
|
158
|
-
|
|
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;
|
|
159
272
|
}
|
|
160
273
|
function state(): OrbitState {
|
|
161
274
|
if (manualReduced || reduced?.matches || canvas.closest('[data-rhine-motion="reduced"]')) return 'reduced';
|
|
162
|
-
if (compact?.matches) return 'compact';
|
|
275
|
+
if (compact?.matches && linework === 'original') return 'compact';
|
|
163
276
|
if (document.hidden) return 'hidden';
|
|
164
277
|
if (!visible) return 'offscreen';
|
|
165
278
|
if (canvases.network.w <= 0 || canvases.network.h <= 0) return 'empty';
|
|
@@ -182,6 +295,7 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
|
|
|
182
295
|
cancelAnimationFrame(frame);
|
|
183
296
|
frame = 0;
|
|
184
297
|
last = null;
|
|
298
|
+
readPalette();
|
|
185
299
|
resize();
|
|
186
300
|
const next = state();
|
|
187
301
|
if (next !== previousState) { previousState = next; onState(next); }
|
|
@@ -194,10 +308,11 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
|
|
|
194
308
|
sync();
|
|
195
309
|
});
|
|
196
310
|
intersection?.observe(canvas);
|
|
197
|
-
const
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
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'] });
|
|
201
316
|
}
|
|
202
317
|
reduced?.addEventListener?.('change', sync);
|
|
203
318
|
compact?.addEventListener?.('change', sync);
|
|
@@ -217,10 +332,12 @@ export function mountOrbit(canvas: HTMLCanvasElement, onState: (state: OrbitStat
|
|
|
217
332
|
},
|
|
218
333
|
dispose() {
|
|
219
334
|
disposed = true;
|
|
335
|
+
for (const artwork of artworks.values()) artwork.dispose();
|
|
336
|
+
artworks.clear(); canvas.removeAttribute('data-orbit-covered');
|
|
220
337
|
cancelAnimationFrame(frame);
|
|
221
338
|
observer?.disconnect();
|
|
222
339
|
intersection?.disconnect();
|
|
223
|
-
|
|
340
|
+
appearance.disconnect();
|
|
224
341
|
reduced?.removeEventListener?.('change', sync);
|
|
225
342
|
compact?.removeEventListener?.('change', sync);
|
|
226
343
|
window.removeEventListener('resize', sync);
|
package/components/styles.css
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
@import './extensions.css';
|
|
7
7
|
@import './background.css';
|
|
8
8
|
@import './orbit.css';
|
|
9
|
+
@import './neutron.css';
|
|
9
10
|
@import './charts.css';
|
|
10
11
|
|
|
11
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; }
|
|
@@ -22,7 +23,8 @@
|
|
|
22
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); }
|
|
23
24
|
.rhine-button--danger { background:var(--rhine-error); border-color:var(--rhine-error); }
|
|
24
25
|
.rhine-button--danger:hover:not(:disabled):not([aria-disabled=true]) { background:#81342e; border-color:#81342e; }
|
|
25
|
-
.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; }
|
|
26
28
|
.rhine-button[aria-busy=true] { cursor:wait; }
|
|
27
29
|
.rhine-button__ghost { visibility:hidden; }
|
|
28
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); }
|