@shulya/liquid-glass 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +336 -2
- package/dist/angular.cjs +78 -0
- package/dist/angular.mjs +76 -0
- package/dist/core.cjs +1119 -0
- package/dist/core.mjs +1119 -0
- package/dist/index.cjs +17 -0
- package/dist/index.mjs +15 -0
- package/dist/react.cjs +105 -0
- package/dist/react.mjs +106 -0
- package/dist/solid.cjs +82 -0
- package/dist/solid.mjs +80 -0
- package/dist/svelte.cjs +29 -0
- package/dist/svelte.mjs +27 -0
- package/dist/tables.cjs +14 -0
- package/dist/tables.mjs +14 -0
- package/dist/vue.cjs +94 -0
- package/dist/vue.mjs +92 -0
- package/example/README.md +37 -0
- package/example/index.html +15 -0
- package/example/package.json +27 -0
- package/example/src/App.jsx +353 -0
- package/example/src/main.jsx +10 -0
- package/example/src/styles.css +155 -0
- package/example/vite.config.js +22 -0
- package/package.json +115 -4
- package/screen.png +0 -0
- package/src/angular.js +76 -0
- package/src/core.js +1119 -0
- package/src/index.js +15 -0
- package/src/react.js +106 -0
- package/src/solid.js +80 -0
- package/src/svelte.js +27 -0
- package/src/tables.js +14 -0
- package/src/vue.js +92 -0
- package/tables/optical_calibration_matrix.csv +257 -0
- package/tables/optical_calibration_matrix.xlsx +0 -0
- package/types/angular.d.ts +24 -0
- package/types/index.d.ts +80 -0
- package/types/react.d.ts +29 -0
- package/types/solid.d.ts +14 -0
- package/types/svelte.d.ts +15 -0
- package/types/vue.d.ts +19 -0
package/dist/vue.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue 3 Adapter for Liquid Glass UI (Component + v-liquid-glass Directive)
|
|
3
|
+
*/
|
|
4
|
+
import { defineComponent, h, ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
|
5
|
+
import { LiquidGlassController, OPTICAL_DEFAULTS } from './core.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Vue Directive: v-liquid-glass="{ rim: 16, ior: 1.46, height: 18, frosted: 0, fusion: true }"
|
|
9
|
+
*/
|
|
10
|
+
export const vLiquidGlass = {
|
|
11
|
+
mounted(el, binding) {
|
|
12
|
+
el.__liquidGlass = new LiquidGlassController(el, binding.value || {});
|
|
13
|
+
},
|
|
14
|
+
updated(el, binding) {
|
|
15
|
+
if (el.__liquidGlass) {
|
|
16
|
+
el.__liquidGlass.update(binding.value || {});
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
unmounted(el) {
|
|
20
|
+
if (el.__liquidGlass) {
|
|
21
|
+
el.__liquidGlass.destroy();
|
|
22
|
+
delete el.__liquidGlass;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* <LiquidGlass> Vue 3 Component
|
|
29
|
+
*/
|
|
30
|
+
export const LiquidGlass = defineComponent({
|
|
31
|
+
name: 'LiquidGlass',
|
|
32
|
+
props: {
|
|
33
|
+
rim: { type: Number, default: OPTICAL_DEFAULTS.rim },
|
|
34
|
+
ior: { type: Number, default: OPTICAL_DEFAULTS.ior },
|
|
35
|
+
height: { type: Number, default: OPTICAL_DEFAULTS.height },
|
|
36
|
+
frosted: { type: Number, default: OPTICAL_DEFAULTS.frosted },
|
|
37
|
+
fusion: { type: Boolean, default: OPTICAL_DEFAULTS.fusion },
|
|
38
|
+
tag: { type: String, default: 'div' }
|
|
39
|
+
},
|
|
40
|
+
setup(props, { slots, attrs }) {
|
|
41
|
+
const rootRef = ref(null);
|
|
42
|
+
let controller = null;
|
|
43
|
+
|
|
44
|
+
onMounted(() => {
|
|
45
|
+
if (rootRef.value) {
|
|
46
|
+
controller = new LiquidGlassController(rootRef.value, {
|
|
47
|
+
rim: props.rim,
|
|
48
|
+
ior: props.ior,
|
|
49
|
+
height: props.height,
|
|
50
|
+
frosted: props.frosted,
|
|
51
|
+
fusion: props.fusion
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
watch(
|
|
57
|
+
() => [props.rim, props.ior, props.height, props.frosted, props.fusion],
|
|
58
|
+
([rim, ior, height, frosted, fusion]) => {
|
|
59
|
+
if (controller) {
|
|
60
|
+
controller.update({ rim, ior, height, frosted, fusion });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
onBeforeUnmount(() => {
|
|
66
|
+
if (controller) {
|
|
67
|
+
controller.destroy();
|
|
68
|
+
controller = null;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return () =>
|
|
73
|
+
h(
|
|
74
|
+
props.tag,
|
|
75
|
+
{
|
|
76
|
+
ref: rootRef,
|
|
77
|
+
...attrs
|
|
78
|
+
},
|
|
79
|
+
slots.default ? slots.default() : null
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const LiquidGlassPlugin = {
|
|
85
|
+
install(app) {
|
|
86
|
+
app.component('LiquidGlass', LiquidGlass);
|
|
87
|
+
app.directive('liquid-glass', vLiquidGlass);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export { LiquidGlassController, OPTICAL_DEFAULTS, OPTICAL_LIMITS } from './core.mjs';
|
|
92
|
+
export default LiquidGlass;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# `@shulya/liquid-glass` — React Interactive Playground (`example/`)
|
|
2
|
+
|
|
3
|
+
- **Live Demo:** [https://liquid.shulya.tech](https://liquid.shulya.tech)
|
|
4
|
+
- **GitHub Repository:** [https://github.com/shulya-tech/liquid-glass](https://github.com/shulya-tech/liquid-glass)
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
This directory contains a complete **React + Vite** application demonstrating the `<LiquidGlass>` component from `@shulya/liquid-glass/react`.
|
|
9
|
+
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
- **Three Draggable `<LiquidGlass>` Components:**
|
|
13
|
+
- **Capsule Slider (`136 × 74 px`, radius `37 px`)** positioned over a green switch track.
|
|
14
|
+
- **Rounded Rectangle (`196 × 116 px`, radius `22 px`)** positioned over an electric blue gradient card.
|
|
15
|
+
- **Circular Lens (`112 × 112 px`, radius `56 px`)** positioned over a warm sunset sphere.
|
|
16
|
+
- **High-Contrast Optical Stage:** Features a fine `56 px` architectural hairline grid, a thin diagonal rainbow ribbon, high-contrast black/white stripes, and centered `"Shulya"` typography to inspect edge refraction, chromatic dispersion, and specular bevel highlights.
|
|
17
|
+
- **Liquid Surface Coalescence (`fusion`):** Drag any two or all three `<LiquidGlass>` elements close together to observe real-time liquid bridge coalescence powered by the precomputed empirical lookup tables (`_T0`–`_T4`), or toggle **Liquid Fusion** off in the bottom control shelf to inspect isolated lenses.
|
|
18
|
+
- **Live React State Controls:**
|
|
19
|
+
- **Bevel Width (`rim`):** `6–32 px` (default `16 px`)
|
|
20
|
+
- **Refraction (`ior`):** `1.10–1.70` (default `1.46`)
|
|
21
|
+
- **Thickness (`height`):** `6–34 px` (default `18 px`)
|
|
22
|
+
- **Frosted Blur (`frosted`):** `0–30 px` (default `0 px`)
|
|
23
|
+
- **Liquid Fusion (`fusion`):** `true / false` toggle
|
|
24
|
+
|
|
25
|
+
## Getting Started
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# 1. From the root of the repository, build the library dist bundles:
|
|
29
|
+
npm run build
|
|
30
|
+
|
|
31
|
+
# 2. Install example dependencies and start the Vite dev server:
|
|
32
|
+
cd example
|
|
33
|
+
npm install
|
|
34
|
+
npm run dev
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then open **`http://localhost:5173`** in your browser.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
7
|
+
<title>Liquid Glass UI — React Interactive Playground</title>
|
|
8
|
+
</head>
|
|
9
|
+
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
13
|
+
</body>
|
|
14
|
+
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "liquid-glass-ui-react-example",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Interactive React playground for @shulya/liquid-glass (https://liquid.shulya.tech)",
|
|
6
|
+
"homepage": "https://liquid.shulya.tech",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/shulya-tech/liquid-glass.git",
|
|
10
|
+
"directory": "example"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "vite",
|
|
15
|
+
"build": "vite build",
|
|
16
|
+
"preview": "vite preview"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"liquid-glass-ui": "file:..",
|
|
20
|
+
"react": "^19.0.0",
|
|
21
|
+
"react-dom": "^19.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
25
|
+
"vite": "^6.2.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
LiquidGlass,
|
|
4
|
+
OPTICAL_DEFAULTS,
|
|
5
|
+
scheduleStageRedraw
|
|
6
|
+
} from '@shulya/liquid-glass/react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Draws a crisp, high-DPI optical test stage onto the background canvas:
|
|
10
|
+
* - Light architectural backdrop (#ebedf2) with fine 56px hairline grid
|
|
11
|
+
* - Sleek green switch pill track (top-left)
|
|
12
|
+
* - Thin diagonal rainbow gradient ribbon
|
|
13
|
+
* - Compact electric blue card (right-center)
|
|
14
|
+
* - Refined sunset radial sphere (bottom-left)
|
|
15
|
+
* - Fine 8px optical stripe grating (bottom-right)
|
|
16
|
+
* - Clean centered "Shulya" typography
|
|
17
|
+
*/
|
|
18
|
+
function drawStageBackground(canvas) {
|
|
19
|
+
if (!canvas) return;
|
|
20
|
+
const w = window.innerWidth;
|
|
21
|
+
const h = window.innerHeight;
|
|
22
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
23
|
+
|
|
24
|
+
canvas.width = Math.floor(w * dpr);
|
|
25
|
+
canvas.height = Math.floor(h * dpr);
|
|
26
|
+
|
|
27
|
+
const ctx = canvas.getContext('2d');
|
|
28
|
+
if (!ctx) return;
|
|
29
|
+
|
|
30
|
+
ctx.save();
|
|
31
|
+
ctx.scale(dpr, dpr);
|
|
32
|
+
|
|
33
|
+
// 1. Clean Light Gray Backdrop (#ebedf2)
|
|
34
|
+
ctx.fillStyle = '#ebedf2';
|
|
35
|
+
ctx.fillRect(0, 0, w, h);
|
|
36
|
+
|
|
37
|
+
// Fine architectural hairline grid (56px step, 0.5px crisp stroke)
|
|
38
|
+
ctx.strokeStyle = 'rgba(0, 0, 0, 0.035)';
|
|
39
|
+
ctx.lineWidth = 0.5;
|
|
40
|
+
const step = 56;
|
|
41
|
+
ctx.beginPath();
|
|
42
|
+
for (let x = 0; x < w; x += step) {
|
|
43
|
+
ctx.moveTo(x + 0.5, 0);
|
|
44
|
+
ctx.lineTo(x + 0.5, h);
|
|
45
|
+
}
|
|
46
|
+
for (let y = 0; y < h; y += step) {
|
|
47
|
+
ctx.moveTo(0, y + 0.5);
|
|
48
|
+
ctx.lineTo(w, y + 0.5);
|
|
49
|
+
}
|
|
50
|
+
ctx.stroke();
|
|
51
|
+
|
|
52
|
+
// 2. Sleek Green Switch Track (Top-Left)
|
|
53
|
+
const pillX = w * 0.16;
|
|
54
|
+
const pillY = h * 0.22;
|
|
55
|
+
const pillW = 160;
|
|
56
|
+
const pillH = 26;
|
|
57
|
+
ctx.save();
|
|
58
|
+
ctx.beginPath();
|
|
59
|
+
ctx.roundRect(pillX - pillW / 2, pillY - pillH / 2, pillW, pillH, pillH / 2);
|
|
60
|
+
ctx.fillStyle = '#32d74b';
|
|
61
|
+
ctx.fill();
|
|
62
|
+
ctx.restore();
|
|
63
|
+
|
|
64
|
+
// 3. Thin Diagonal Rainbow Gradient Ribbon
|
|
65
|
+
ctx.save();
|
|
66
|
+
ctx.translate(w * 0.5, h * 0.51);
|
|
67
|
+
ctx.rotate(-0.2);
|
|
68
|
+
const ribHalfLen = Math.min(w * 0.28, 310);
|
|
69
|
+
const ribThickness = 26;
|
|
70
|
+
const ribGrad = ctx.createLinearGradient(-ribHalfLen, 0, ribHalfLen, 0);
|
|
71
|
+
ribGrad.addColorStop(0.0, '#ff2d55');
|
|
72
|
+
ribGrad.addColorStop(0.25, '#ff9500');
|
|
73
|
+
ribGrad.addColorStop(0.5, '#ffcc00');
|
|
74
|
+
ribGrad.addColorStop(0.75, '#5ac8fa');
|
|
75
|
+
ribGrad.addColorStop(1.0, '#5856d6');
|
|
76
|
+
ctx.fillStyle = ribGrad;
|
|
77
|
+
ctx.beginPath();
|
|
78
|
+
ctx.roundRect(
|
|
79
|
+
-ribHalfLen,
|
|
80
|
+
-ribThickness / 2,
|
|
81
|
+
ribHalfLen * 2,
|
|
82
|
+
ribThickness,
|
|
83
|
+
ribThickness / 2
|
|
84
|
+
);
|
|
85
|
+
ctx.fill();
|
|
86
|
+
ctx.restore();
|
|
87
|
+
|
|
88
|
+
// 4. Refined Electric Blue Card (Right Center)
|
|
89
|
+
ctx.save();
|
|
90
|
+
const cardX = w * 0.67;
|
|
91
|
+
const cardY = h * 0.24;
|
|
92
|
+
const cardW = 152;
|
|
93
|
+
const cardH = 88;
|
|
94
|
+
const blueGrad = ctx.createLinearGradient(cardX, cardY, cardX + cardW, cardY + cardH);
|
|
95
|
+
blueGrad.addColorStop(0, '#007aff');
|
|
96
|
+
blueGrad.addColorStop(1, '#5856d6');
|
|
97
|
+
ctx.fillStyle = blueGrad;
|
|
98
|
+
ctx.beginPath();
|
|
99
|
+
ctx.roundRect(cardX, cardY, cardW, cardH, 22);
|
|
100
|
+
ctx.fill();
|
|
101
|
+
ctx.restore();
|
|
102
|
+
|
|
103
|
+
// 5. Compact Sunset Orange Sphere (Bottom Left)
|
|
104
|
+
ctx.save();
|
|
105
|
+
const orbX = w * 0.26;
|
|
106
|
+
const orbY = h * 0.67;
|
|
107
|
+
const orbR = 54;
|
|
108
|
+
const orbGrad = ctx.createRadialGradient(orbX - 14, orbY - 14, 6, orbX, orbY, orbR);
|
|
109
|
+
orbGrad.addColorStop(0, '#ffcc00');
|
|
110
|
+
orbGrad.addColorStop(0.6, '#ff3b30');
|
|
111
|
+
orbGrad.addColorStop(1, '#c91f66');
|
|
112
|
+
ctx.fillStyle = orbGrad;
|
|
113
|
+
ctx.beginPath();
|
|
114
|
+
ctx.arc(orbX, orbY, orbR, 0, Math.PI * 2);
|
|
115
|
+
ctx.fill();
|
|
116
|
+
ctx.restore();
|
|
117
|
+
|
|
118
|
+
// 6. Fine Optical Stripe Grating (Bottom Right — 10px stripes for refraction testing)
|
|
119
|
+
ctx.save();
|
|
120
|
+
const stX = w * 0.63;
|
|
121
|
+
const stY = h * 0.62;
|
|
122
|
+
const stW = 160;
|
|
123
|
+
const stH = 92;
|
|
124
|
+
const stripeWidth = 10;
|
|
125
|
+
ctx.beginPath();
|
|
126
|
+
ctx.roundRect(stX, stY, stW, stH, 18);
|
|
127
|
+
ctx.clip();
|
|
128
|
+
for (let i = 0; i < stW / stripeWidth; i++) {
|
|
129
|
+
ctx.fillStyle = i % 2 === 0 ? '#1c1c1e' : '#ffffff';
|
|
130
|
+
ctx.fillRect(stX + i * stripeWidth, stY, stripeWidth, stH);
|
|
131
|
+
}
|
|
132
|
+
ctx.restore();
|
|
133
|
+
|
|
134
|
+
// 7. Sleek Center Typography ("Shulya")
|
|
135
|
+
ctx.save();
|
|
136
|
+
const fontSize = Math.min(w * 0.082, 92);
|
|
137
|
+
ctx.font = `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif`;
|
|
138
|
+
ctx.textAlign = 'center';
|
|
139
|
+
ctx.textBaseline = 'middle';
|
|
140
|
+
ctx.fillStyle = '#16181d';
|
|
141
|
+
ctx.fillText('Shulya', w * 0.5, h * 0.43);
|
|
142
|
+
ctx.restore();
|
|
143
|
+
|
|
144
|
+
ctx.restore();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default function App() {
|
|
148
|
+
const stageRef = useRef(null);
|
|
149
|
+
|
|
150
|
+
// Live optical configuration parameters bound to <LiquidGlass> props
|
|
151
|
+
const [rim, setRim] = useState(OPTICAL_DEFAULTS.rim);
|
|
152
|
+
const [ior, setIor] = useState(OPTICAL_DEFAULTS.ior);
|
|
153
|
+
const [height, setHeight] = useState(OPTICAL_DEFAULTS.height);
|
|
154
|
+
const [frosted, setFrosted] = useState(OPTICAL_DEFAULTS.frosted);
|
|
155
|
+
const [fusion, setFusion] = useState(OPTICAL_DEFAULTS.fusion);
|
|
156
|
+
const [fps, setFps] = useState(60);
|
|
157
|
+
|
|
158
|
+
// Three sleek draggable LiquidGlass bodies
|
|
159
|
+
const [shapes, setShapes] = useState(() => {
|
|
160
|
+
const w = typeof window !== 'undefined' ? window.innerWidth : 1280;
|
|
161
|
+
const h = typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
162
|
+
return [
|
|
163
|
+
{ id: 'slider', x: w * 0.16 + 42, y: h * 0.22, w: 136, h: 74, r: 37 },
|
|
164
|
+
{ id: 'rect', x: w * 0.71, y: h * 0.21, w: 196, h: 116, r: 22 },
|
|
165
|
+
{ id: 'circle', x: w * 0.22, y: h * 0.65, w: 112, h: 112, r: 56 }
|
|
166
|
+
];
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const dragState = useRef(null);
|
|
170
|
+
|
|
171
|
+
// Draw stage canvas and handle window resize
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
drawStageBackground(stageRef.current);
|
|
174
|
+
scheduleStageRedraw();
|
|
175
|
+
|
|
176
|
+
const handleResize = () => {
|
|
177
|
+
drawStageBackground(stageRef.current);
|
|
178
|
+
scheduleStageRedraw();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
window.addEventListener('resize', handleResize);
|
|
182
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
// FPS monitor
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
let frameCount = 0;
|
|
188
|
+
let lastTime = performance.now();
|
|
189
|
+
let rafId;
|
|
190
|
+
|
|
191
|
+
const tick = (now) => {
|
|
192
|
+
frameCount++;
|
|
193
|
+
if (now - lastTime >= 500) {
|
|
194
|
+
setFps(Math.min(120, Math.round((frameCount * 1000) / (now - lastTime))));
|
|
195
|
+
frameCount = 0;
|
|
196
|
+
lastTime = now;
|
|
197
|
+
}
|
|
198
|
+
rafId = requestAnimationFrame(tick);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
rafId = requestAnimationFrame(tick);
|
|
202
|
+
return () => cancelAnimationFrame(rafId);
|
|
203
|
+
}, []);
|
|
204
|
+
|
|
205
|
+
// Pointer dragging handlers
|
|
206
|
+
const handlePointerDown = useCallback((id, e) => {
|
|
207
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
208
|
+
setShapes((prev) => {
|
|
209
|
+
const idx = prev.findIndex((s) => s.id === id);
|
|
210
|
+
if (idx === -1) return prev;
|
|
211
|
+
const target = prev[idx];
|
|
212
|
+
dragState.current = {
|
|
213
|
+
id,
|
|
214
|
+
offX: e.clientX - target.x,
|
|
215
|
+
offY: e.clientY - target.y
|
|
216
|
+
};
|
|
217
|
+
// Move active dragged shape to top of render order
|
|
218
|
+
const next = [...prev];
|
|
219
|
+
const [picked] = next.splice(idx, 1);
|
|
220
|
+
next.push(picked);
|
|
221
|
+
return next;
|
|
222
|
+
});
|
|
223
|
+
}, []);
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
const handlePointerMove = (e) => {
|
|
227
|
+
if (!dragState.current) return;
|
|
228
|
+
const { id, offX, offY } = dragState.current;
|
|
229
|
+
const nextX = e.clientX - offX;
|
|
230
|
+
const nextY = e.clientY - offY;
|
|
231
|
+
|
|
232
|
+
setShapes((prev) =>
|
|
233
|
+
prev.map((s) => (s.id === id ? { ...s, x: nextX, y: nextY } : s))
|
|
234
|
+
);
|
|
235
|
+
scheduleStageRedraw();
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const handlePointerUp = () => {
|
|
239
|
+
dragState.current = null;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
window.addEventListener('pointermove', handlePointerMove);
|
|
243
|
+
window.addEventListener('pointerup', handlePointerUp);
|
|
244
|
+
return () => {
|
|
245
|
+
window.removeEventListener('pointermove', handlePointerMove);
|
|
246
|
+
window.removeEventListener('pointerup', handlePointerUp);
|
|
247
|
+
};
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<>
|
|
252
|
+
{/* Background Optical Test Stage */}
|
|
253
|
+
<canvas ref={stageRef} className="stage-canvas" />
|
|
254
|
+
|
|
255
|
+
{/* Three Draggable <LiquidGlass> React Components */}
|
|
256
|
+
{shapes.map((s, index) => (
|
|
257
|
+
<LiquidGlass
|
|
258
|
+
key={s.id}
|
|
259
|
+
rim={rim}
|
|
260
|
+
ior={ior}
|
|
261
|
+
height={height}
|
|
262
|
+
frosted={frosted}
|
|
263
|
+
fusion={fusion}
|
|
264
|
+
className="glass-body"
|
|
265
|
+
data-lg-tint="rgba(255, 255, 255, 0.03)"
|
|
266
|
+
onPointerDown={(e) => handlePointerDown(s.id, e)}
|
|
267
|
+
style={{
|
|
268
|
+
width: `${s.w}px`,
|
|
269
|
+
height: `${s.h}px`,
|
|
270
|
+
borderRadius: `${s.r}px`,
|
|
271
|
+
left: `${Math.round(s.x - s.w * 0.5)}px`,
|
|
272
|
+
top: `${Math.round(s.y - s.h * 0.5)}px`,
|
|
273
|
+
zIndex: 20 + index
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
))}
|
|
277
|
+
|
|
278
|
+
{/* Bottom Interactive Control Shelf */}
|
|
279
|
+
<div className="shelf-bar">
|
|
280
|
+
<div className="ctrl">
|
|
281
|
+
<div className="ctrl-head">
|
|
282
|
+
<span>Bevel Width (Rim)</span>
|
|
283
|
+
<span className="ctrl-val">{rim} px</span>
|
|
284
|
+
</div>
|
|
285
|
+
<input
|
|
286
|
+
type="range"
|
|
287
|
+
min="6"
|
|
288
|
+
max="32"
|
|
289
|
+
step="1"
|
|
290
|
+
value={rim}
|
|
291
|
+
onChange={(e) => setRim(Number(e.target.value))}
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<div className="ctrl">
|
|
296
|
+
<div className="ctrl-head">
|
|
297
|
+
<span>Refraction (IOR)</span>
|
|
298
|
+
<span className="ctrl-val">{ior.toFixed(2)}</span>
|
|
299
|
+
</div>
|
|
300
|
+
<input
|
|
301
|
+
type="range"
|
|
302
|
+
min="1.10"
|
|
303
|
+
max="1.70"
|
|
304
|
+
step="0.02"
|
|
305
|
+
value={ior}
|
|
306
|
+
onChange={(e) => setIor(Number(e.target.value))}
|
|
307
|
+
/>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<div className="ctrl">
|
|
311
|
+
<div className="ctrl-head">
|
|
312
|
+
<span>Thickness (Height)</span>
|
|
313
|
+
<span className="ctrl-val">{height} px</span>
|
|
314
|
+
</div>
|
|
315
|
+
<input
|
|
316
|
+
type="range"
|
|
317
|
+
min="6"
|
|
318
|
+
max="34"
|
|
319
|
+
step="1"
|
|
320
|
+
value={height}
|
|
321
|
+
onChange={(e) => setHeight(Number(e.target.value))}
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<div className="ctrl">
|
|
326
|
+
<div className="ctrl-head">
|
|
327
|
+
<span>Frosted Blur</span>
|
|
328
|
+
<span className="ctrl-val">{frosted} px</span>
|
|
329
|
+
</div>
|
|
330
|
+
<input
|
|
331
|
+
type="range"
|
|
332
|
+
min="0"
|
|
333
|
+
max="30"
|
|
334
|
+
step="1"
|
|
335
|
+
value={frosted}
|
|
336
|
+
onChange={(e) => setFrosted(Number(e.target.value))}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<label className="ctrl-toggle">
|
|
341
|
+
<input
|
|
342
|
+
type="checkbox"
|
|
343
|
+
checked={fusion}
|
|
344
|
+
onChange={(e) => setFusion(e.target.checked)}
|
|
345
|
+
/>
|
|
346
|
+
<span>Liquid Fusion</span>
|
|
347
|
+
</label>
|
|
348
|
+
|
|
349
|
+
<div className="fps-tag">{fps} FPS</div>
|
|
350
|
+
</div>
|
|
351
|
+
</>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
*,
|
|
2
|
+
*::before,
|
|
3
|
+
*::after {
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
margin: 0;
|
|
6
|
+
padding: 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
html,
|
|
10
|
+
body,
|
|
11
|
+
#root {
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: 100%;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
background: #ebedf2;
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif;
|
|
17
|
+
user-select: none;
|
|
18
|
+
-webkit-user-select: none;
|
|
19
|
+
touch-action: none;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.stage-canvas {
|
|
23
|
+
position: fixed;
|
|
24
|
+
inset: 0;
|
|
25
|
+
width: 100vw;
|
|
26
|
+
height: 100vh;
|
|
27
|
+
display: block;
|
|
28
|
+
z-index: 1;
|
|
29
|
+
pointer-events: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Interactive Draggable LiquidGlass Elements */
|
|
33
|
+
.glass-body {
|
|
34
|
+
position: fixed;
|
|
35
|
+
top: 0;
|
|
36
|
+
left: 0;
|
|
37
|
+
z-index: 20;
|
|
38
|
+
cursor: grab;
|
|
39
|
+
touch-action: none;
|
|
40
|
+
will-change: left, top;
|
|
41
|
+
box-shadow: 2px 10px 18px rgba(0, 0, 0, 0.12);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.glass-body:active {
|
|
45
|
+
cursor: grabbing;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Bottom Control Shelf */
|
|
49
|
+
.shelf-bar {
|
|
50
|
+
position: fixed;
|
|
51
|
+
bottom: 20px;
|
|
52
|
+
left: 50%;
|
|
53
|
+
transform: translateX(-50%);
|
|
54
|
+
z-index: 60;
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 18px;
|
|
58
|
+
padding: 10px 20px;
|
|
59
|
+
border-radius: 999px;
|
|
60
|
+
background: rgba(255, 255, 255, 0.8);
|
|
61
|
+
backdrop-filter: blur(20px) saturate(180%);
|
|
62
|
+
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
|
63
|
+
border: 1px solid rgba(255, 255, 255, 0.9);
|
|
64
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07), 0 2px 6px rgba(0, 0, 0, 0.03);
|
|
65
|
+
color: #1c1c1e;
|
|
66
|
+
flex-wrap: wrap;
|
|
67
|
+
justify-content: center;
|
|
68
|
+
max-width: 95vw;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.ctrl {
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-direction: column;
|
|
74
|
+
gap: 4px;
|
|
75
|
+
min-width: 110px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.ctrl-head {
|
|
79
|
+
display: flex;
|
|
80
|
+
justify-content: space-between;
|
|
81
|
+
font-size: 10.5px;
|
|
82
|
+
font-weight: 600;
|
|
83
|
+
color: #636366;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ctrl-val {
|
|
87
|
+
color: #007aff;
|
|
88
|
+
font-variant-numeric: tabular-nums;
|
|
89
|
+
font-weight: 700;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
input[type="range"] {
|
|
93
|
+
-webkit-appearance: none;
|
|
94
|
+
appearance: none;
|
|
95
|
+
width: 100%;
|
|
96
|
+
height: 4px;
|
|
97
|
+
border-radius: 999px;
|
|
98
|
+
background: rgba(0, 0, 0, 0.1);
|
|
99
|
+
outline: none;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
input[type="range"]::-webkit-slider-thumb {
|
|
104
|
+
-webkit-appearance: none;
|
|
105
|
+
appearance: none;
|
|
106
|
+
width: 14px;
|
|
107
|
+
height: 14px;
|
|
108
|
+
border-radius: 50%;
|
|
109
|
+
background: #ffffff;
|
|
110
|
+
border: 1.5px solid rgba(0, 122, 255, 0.6);
|
|
111
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.ctrl-toggle {
|
|
115
|
+
display: flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: 6px;
|
|
118
|
+
font-size: 11px;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
color: #3a3a3c;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
padding: 4px 10px;
|
|
123
|
+
border-radius: 999px;
|
|
124
|
+
background: rgba(0, 0, 0, 0.04);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.ctrl-toggle input {
|
|
128
|
+
accent-color: #007aff;
|
|
129
|
+
width: 13px;
|
|
130
|
+
height: 13px;
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.fps-tag {
|
|
135
|
+
font-size: 10.5px;
|
|
136
|
+
font-weight: 700;
|
|
137
|
+
color: #30d158;
|
|
138
|
+
background: rgba(48, 209, 88, 0.12);
|
|
139
|
+
padding: 4px 10px;
|
|
140
|
+
border-radius: 999px;
|
|
141
|
+
font-variant-numeric: tabular-nums;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@media (max-width: 720px) {
|
|
145
|
+
.shelf-bar {
|
|
146
|
+
gap: 10px;
|
|
147
|
+
padding: 10px 14px;
|
|
148
|
+
border-radius: 20px;
|
|
149
|
+
bottom: 12px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.ctrl {
|
|
153
|
+
min-width: 96px;
|
|
154
|
+
}
|
|
155
|
+
}
|