@lynx-example/design-guide 0.1.0 → 0.2.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/dist/color_wheels.lynx.bundle +0 -0
- package/dist/color_wheels.web.bundle +1 -0
- package/dist/force_field.lynx.bundle +0 -0
- package/dist/force_field.web.bundle +1 -0
- package/dist/gooey_effect.lynx.bundle +0 -0
- package/dist/gooey_effect.web.bundle +1 -1
- package/lynx.config.mjs +6 -1
- package/package.json +2 -1
- package/src/color_wheels/index.css +25 -47
- package/src/color_wheels/index.tsx +7 -6
- package/src/force_field/cells.ts +20 -0
- package/src/force_field/color.ts +58 -0
- package/src/force_field/field-force.ts +97 -0
- package/src/force_field/field.css +23 -0
- package/src/force_field/field.tsx +55 -0
- package/src/force_field/field.types.ts +50 -0
- package/src/force_field/index.css +17 -0
- package/src/force_field/index.tsx +100 -0
- package/src/force_field/math.ts +8 -0
- package/src/gooey_effect/index.css +19 -38
- package/src/gooey_effect/index.tsx +7 -6
- package/src/shared/components/caption/index.css +27 -0
- package/src/shared/components/caption/index.tsx +17 -0
- package/src/shared/hooks/use-pointer-field-point/index.ts +85 -0
- package/src/shared/hooks/use-pointer-interaction/index.ts +100 -0
- package/src/shared/hooks/use-pointer-interaction/types.ts +30 -0
- package/dist/conic_gradient.lynx.bundle +0 -0
- package/dist/conic_gradient.web.bundle +0 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type Cell = { id: string; i: number; j: number; cx: number; cy: number };
|
|
2
|
+
|
|
3
|
+
const cols = 20;
|
|
4
|
+
const rows = 20;
|
|
5
|
+
const CELLS: Cell[] = [];
|
|
6
|
+
|
|
7
|
+
for (let c = 0; c < cols; c++) {
|
|
8
|
+
for (let r = 0; r < rows; r++) {
|
|
9
|
+
CELLS.push({
|
|
10
|
+
id: `${r}-${c}`,
|
|
11
|
+
i: r,
|
|
12
|
+
j: c,
|
|
13
|
+
// normalized cell center in [0,1]
|
|
14
|
+
cx: (c + 0.5) / cols,
|
|
15
|
+
cy: (r + 0.5) / rows,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { CELLS };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { clamp01 } from "./math.js";
|
|
2
|
+
|
|
3
|
+
function lerpColor(
|
|
4
|
+
t: number,
|
|
5
|
+
colorA: string,
|
|
6
|
+
colorB: string,
|
|
7
|
+
): string {
|
|
8
|
+
const [rA, gA, bA] = hexToRgb(colorA);
|
|
9
|
+
const [rB, gB, bB] = hexToRgb(colorB);
|
|
10
|
+
|
|
11
|
+
const r = mix(rA, rB, t);
|
|
12
|
+
const g = mix(gA, gB, t);
|
|
13
|
+
const b = mix(bA, bB, t);
|
|
14
|
+
|
|
15
|
+
return rgbToHex(r, g, b);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
19
|
+
const n = hex.replace("#", "");
|
|
20
|
+
return [
|
|
21
|
+
parseInt(n.slice(0, 2), 16),
|
|
22
|
+
parseInt(n.slice(2, 4), 16),
|
|
23
|
+
parseInt(n.slice(4, 6), 16),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
28
|
+
return (
|
|
29
|
+
"#"
|
|
30
|
+
+ [r, g, b]
|
|
31
|
+
.map(v => Math.round(v).toString(16).padStart(2, "0"))
|
|
32
|
+
.join("")
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mix(a: number, b: number, t: number) {
|
|
37
|
+
return a * (1 - t) + b * t;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lerpColor4(
|
|
41
|
+
t: number,
|
|
42
|
+
c0: string,
|
|
43
|
+
c1: string,
|
|
44
|
+
c2: string,
|
|
45
|
+
c3: string,
|
|
46
|
+
): string {
|
|
47
|
+
const x = clamp01(t);
|
|
48
|
+
|
|
49
|
+
if (x < 1 / 3) {
|
|
50
|
+
return lerpColor(x / (1 / 3), c0, c1);
|
|
51
|
+
}
|
|
52
|
+
if (x < 2 / 3) {
|
|
53
|
+
return lerpColor((x - 1 / 3) / (1 / 3), c1, c2);
|
|
54
|
+
}
|
|
55
|
+
return lerpColor((x - 2 / 3) / (1 / 3), c2, c3);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { lerpColor, lerpColor4 };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { smoothstep01 } from "./math.js";
|
|
2
|
+
export type Vec2 = { x: number; y: number };
|
|
3
|
+
|
|
4
|
+
export type ForceFieldOptions = {
|
|
5
|
+
// Core shape
|
|
6
|
+
/**
|
|
7
|
+
* influence radius in normalized space (0..sqrt(2))
|
|
8
|
+
*/
|
|
9
|
+
radius?: number;
|
|
10
|
+
/**
|
|
11
|
+
* overall displacement magnitude (normalized units)
|
|
12
|
+
*/
|
|
13
|
+
strength?: number;
|
|
14
|
+
/**
|
|
15
|
+
* higher = faster decay
|
|
16
|
+
*/
|
|
17
|
+
falloff?: number;
|
|
18
|
+
mode?: "repel" | "attract";
|
|
19
|
+
|
|
20
|
+
// Optional style
|
|
21
|
+
/**
|
|
22
|
+
* tangential component, gives "vortex" feel
|
|
23
|
+
*/
|
|
24
|
+
swirl?: number;
|
|
25
|
+
/**
|
|
26
|
+
* avoids singularity near center
|
|
27
|
+
*/
|
|
28
|
+
soften?: number;
|
|
29
|
+
/**
|
|
30
|
+
* max displacement magnitude
|
|
31
|
+
*/
|
|
32
|
+
clampMax?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const DEFAULTS: Required<ForceFieldOptions> = {
|
|
36
|
+
radius: 0.45,
|
|
37
|
+
strength: 0.085,
|
|
38
|
+
falloff: 2.2,
|
|
39
|
+
mode: "repel",
|
|
40
|
+
swirl: 0.0,
|
|
41
|
+
soften: 0.02,
|
|
42
|
+
clampMax: 0.12,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function makeForceField(user: ForceFieldOptions = {}) {
|
|
46
|
+
const opt = { ...DEFAULTS, ...user };
|
|
47
|
+
|
|
48
|
+
return function forceAt(cell: Vec2, p: Vec2) {
|
|
49
|
+
// Vector from p -> cell
|
|
50
|
+
const vx = cell.x - p.x;
|
|
51
|
+
const vy = cell.y - p.y;
|
|
52
|
+
const d = Math.sqrt(vx * vx + vy * vy);
|
|
53
|
+
|
|
54
|
+
// Outside radius: no force
|
|
55
|
+
if (d >= opt.radius) return { dx: 0, dy: 0, t: 1, d };
|
|
56
|
+
|
|
57
|
+
// Normalize direction (avoid NaN at center)
|
|
58
|
+
const inv = 1 / Math.max(d, opt.soften);
|
|
59
|
+
const nx = vx * inv;
|
|
60
|
+
const ny = vy * inv;
|
|
61
|
+
|
|
62
|
+
// 0..1 influence inside radius (1 at center, 0 at edge)
|
|
63
|
+
const u = 1 - d / opt.radius;
|
|
64
|
+
|
|
65
|
+
// Smooth & decay
|
|
66
|
+
const influence = smoothstep01(u);
|
|
67
|
+
const decay = Math.pow(influence, opt.falloff);
|
|
68
|
+
|
|
69
|
+
// Base radial magnitude
|
|
70
|
+
let mag = opt.strength * decay;
|
|
71
|
+
|
|
72
|
+
// Repel vs attract
|
|
73
|
+
if (opt.mode === "attract") mag = -mag;
|
|
74
|
+
|
|
75
|
+
// Add optional swirl (tangential)
|
|
76
|
+
// Tangent is perpendicular to radial: ( -ny, nx )
|
|
77
|
+
const tx = -ny;
|
|
78
|
+
const ty = nx;
|
|
79
|
+
|
|
80
|
+
let dx = nx * mag + tx * (opt.swirl * mag);
|
|
81
|
+
let dy = ny * mag + ty * (opt.swirl * mag);
|
|
82
|
+
|
|
83
|
+
// Clamp displacement magnitude for stability
|
|
84
|
+
const m = Math.sqrt(dx * dx + dy * dy);
|
|
85
|
+
if (m > opt.clampMax) {
|
|
86
|
+
const s = opt.clampMax / m;
|
|
87
|
+
dx *= s;
|
|
88
|
+
dy *= s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// t: a handy 0..1 "field value" you can reuse for color/size
|
|
92
|
+
// Here we invert so t=0 near center, t=1 far away.
|
|
93
|
+
const t = 1 - decay;
|
|
94
|
+
|
|
95
|
+
return { dx, dy, t, d };
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.dot-field {
|
|
2
|
+
position: relative;
|
|
3
|
+
--dot-size: 5px;
|
|
4
|
+
--dot-color: #f8f8f8;
|
|
5
|
+
--dot-accent-color: #ffff00;
|
|
6
|
+
--field-size: 300px;
|
|
7
|
+
width: var(--field-size);
|
|
8
|
+
height: var(--field-size);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.dot {
|
|
12
|
+
width: calc(var(--dot-size) * var(--s, 1));
|
|
13
|
+
height: calc(var(--dot-size) * var(--s, 1));
|
|
14
|
+
transform: translate(
|
|
15
|
+
calc(var(--field-size) * var(--x, 0)),
|
|
16
|
+
calc(var(--field-size) * var(--y, 0))
|
|
17
|
+
);
|
|
18
|
+
background-color: var(--dot-color, #f8f8f8);
|
|
19
|
+
border-radius: 9999px;
|
|
20
|
+
position: absolute;
|
|
21
|
+
top: 0;
|
|
22
|
+
left: 0;
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DotFieldProps, DotProps } from "./field.types.js";
|
|
2
|
+
|
|
3
|
+
import "./field.css";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Dot consumes normalized, unitless styling parameters.
|
|
7
|
+
* Concrete layout and sizing are resolved against field-level
|
|
8
|
+
* CSS variables, rather than computed imperatively in JS.
|
|
9
|
+
*/
|
|
10
|
+
function Dot({ x = 0, y = 0, s = 1, color, useAccent }: DotProps) {
|
|
11
|
+
return (
|
|
12
|
+
<view
|
|
13
|
+
className="dot"
|
|
14
|
+
// Inline CSS var reference: route to a field-scoped CSS token (`--dot-accent-color`)
|
|
15
|
+
// instead of resolving the color imperatively in JS.
|
|
16
|
+
style={`--x:${x}; --y:${y}; --s:${s}; background-color:${useAccent ? "var(--dot-accent-color)" : color}`}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DotField defines the styling boundary for a dot-based field.
|
|
23
|
+
* It provides a scoped coordinate system and field-level design tokens
|
|
24
|
+
* (e.g. --field-size, --dot-size) via CSS variables.
|
|
25
|
+
*/
|
|
26
|
+
function DotField({
|
|
27
|
+
fieldSize = 200,
|
|
28
|
+
dotSize = 5,
|
|
29
|
+
dotColor = "#f8f8f8",
|
|
30
|
+
dotAccentColor = "#ffff00",
|
|
31
|
+
children,
|
|
32
|
+
bindtouchstart,
|
|
33
|
+
bindtouchmove,
|
|
34
|
+
bindtouchend,
|
|
35
|
+
bindtouchcancel,
|
|
36
|
+
bindlayoutchange,
|
|
37
|
+
}: DotFieldProps) {
|
|
38
|
+
return (
|
|
39
|
+
<view
|
|
40
|
+
/* Styling boundary: inject field-level CSS tokens */
|
|
41
|
+
style={`--field-size:${fieldSize}px; --dot-size:${dotSize}px; --dot-color:${dotColor}; --dot-accent-color:${dotAccentColor} `}
|
|
42
|
+
className="dot-field"
|
|
43
|
+
/* Interaction */
|
|
44
|
+
bindtouchstart={bindtouchstart}
|
|
45
|
+
bindtouchmove={bindtouchmove}
|
|
46
|
+
bindtouchend={bindtouchend}
|
|
47
|
+
bindtouchcancel={bindtouchcancel}
|
|
48
|
+
bindlayoutchange={bindlayoutchange}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</view>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { Dot, DotField };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactNode } from "@lynx-js/react";
|
|
2
|
+
import type { LayoutChangeEvent, TouchEvent } from "@lynx-js/types";
|
|
3
|
+
|
|
4
|
+
type DotProps = {
|
|
5
|
+
/** Normalized x position in the field (0..1). */
|
|
6
|
+
x?: number;
|
|
7
|
+
|
|
8
|
+
/** Normalized y position in the field (0..1). */
|
|
9
|
+
y?: number;
|
|
10
|
+
|
|
11
|
+
/** Relative scale factor, resolved against `--dot-size`. Defaults to 1. */
|
|
12
|
+
s?: number;
|
|
13
|
+
|
|
14
|
+
/** Optional explicit color for this dot. */
|
|
15
|
+
color?: string;
|
|
16
|
+
|
|
17
|
+
/** Use the field-level accent color (`--dot-accent-color`). */
|
|
18
|
+
useAccent?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type DotFieldProps = {
|
|
22
|
+
/** Field size in pixels (defines the coordinate space). */
|
|
23
|
+
fieldSize?: number;
|
|
24
|
+
|
|
25
|
+
/** Base dot size in pixels. */
|
|
26
|
+
dotSize?: number;
|
|
27
|
+
|
|
28
|
+
/** Default dot color (`--dot-color`). */
|
|
29
|
+
dotColor?: string;
|
|
30
|
+
|
|
31
|
+
/** Accent dot color (`--dot-accent-color`). */
|
|
32
|
+
dotAccentColor?: string;
|
|
33
|
+
|
|
34
|
+
/** Extra inline CSS applied at the field boundary. */
|
|
35
|
+
style?: string;
|
|
36
|
+
|
|
37
|
+
/** Field children (dots or other field-aware elements). */
|
|
38
|
+
children?: ReactNode;
|
|
39
|
+
|
|
40
|
+
/** Pointer / touch handlers bound at the field level. */
|
|
41
|
+
bindtouchstart?: (e: TouchEvent) => void;
|
|
42
|
+
bindtouchmove?: (e: TouchEvent) => void;
|
|
43
|
+
bindtouchend?: (e: TouchEvent) => void;
|
|
44
|
+
bindtouchcancel?: (e: TouchEvent) => void;
|
|
45
|
+
|
|
46
|
+
/** Layout measurement handler for field coordinate mapping. */
|
|
47
|
+
bindlayoutchange?: (e: LayoutChangeEvent) => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type { DotFieldProps, DotProps };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* =========================
|
|
2
|
+
* Layout
|
|
3
|
+
* ========================= */
|
|
4
|
+
|
|
5
|
+
.design-container {
|
|
6
|
+
width: 100%;
|
|
7
|
+
height: 100%;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
align-items: center;
|
|
12
|
+
background-color: #0d0d0d;
|
|
13
|
+
gap: 24px;
|
|
14
|
+
padding: 84px;
|
|
15
|
+
padding-left: 48px;
|
|
16
|
+
padding-right: 48px;
|
|
17
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { root } from "@lynx-js/react";
|
|
2
|
+
import { useMemo } from "@lynx-js/react";
|
|
3
|
+
|
|
4
|
+
import { Caption } from "../shared/components/caption/index.jsx";
|
|
5
|
+
import { usePointerFieldPoint } from "../shared/hooks/use-pointer-field-point/index.js";
|
|
6
|
+
import { CELLS } from "./cells.js";
|
|
7
|
+
import { lerpColor4 } from "./color.js";
|
|
8
|
+
import { makeForceField } from "./field-force.js";
|
|
9
|
+
import { Dot, DotField } from "./field.jsx";
|
|
10
|
+
import { clamp01 } from "./math.js";
|
|
11
|
+
import "./index.css";
|
|
12
|
+
|
|
13
|
+
const RADIUS = 0.60;
|
|
14
|
+
|
|
15
|
+
const forceAt = makeForceField({
|
|
16
|
+
radius: RADIUS,
|
|
17
|
+
strength: 0.14,
|
|
18
|
+
falloff: 1.8,
|
|
19
|
+
mode: "repel",
|
|
20
|
+
swirl: 0.6,
|
|
21
|
+
clampMax: 0.14,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function App() {
|
|
25
|
+
const {
|
|
26
|
+
p, // Force point in normalized space
|
|
27
|
+
handlePointerDown,
|
|
28
|
+
handlePointerMove,
|
|
29
|
+
handlePointerUp,
|
|
30
|
+
handleElementLayoutChange,
|
|
31
|
+
} = usePointerFieldPoint({ x0: 0.6, y0: 1 });
|
|
32
|
+
|
|
33
|
+
const models = useMemo(() => {
|
|
34
|
+
let minD = Infinity;
|
|
35
|
+
let accentId: string | null = null;
|
|
36
|
+
|
|
37
|
+
// First pass: compute everything and track nearest dot
|
|
38
|
+
const tmp = CELLS.map((cell) => {
|
|
39
|
+
const f = forceAt({ x: cell.cx, y: cell.cy }, p);
|
|
40
|
+
|
|
41
|
+
if (f.d < minD) {
|
|
42
|
+
minD = f.d;
|
|
43
|
+
accentId = cell.id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const x = clamp01(cell.cx + f.dx);
|
|
47
|
+
const y = clamp01(cell.cy + f.dy);
|
|
48
|
+
|
|
49
|
+
const dist01 = clamp01(f.d / RADIUS);
|
|
50
|
+
const g = Math.pow(dist01, 1.25);
|
|
51
|
+
|
|
52
|
+
const s = 0.6 + (1 - g) * 1.8;
|
|
53
|
+
const color = lerpColor4(g, "#EBF4F6", "#7AB2B2", "#088395", "#09637E");
|
|
54
|
+
|
|
55
|
+
return { id: cell.id, x, y, s, color };
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Second pass: mark accent
|
|
59
|
+
return tmp.map((m) => ({
|
|
60
|
+
...m,
|
|
61
|
+
useAccent: m.id === accentId,
|
|
62
|
+
}));
|
|
63
|
+
}, [p.x, p.y]);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<view className="design-container">
|
|
67
|
+
<DotField
|
|
68
|
+
fieldSize={300}
|
|
69
|
+
dotSize={5}
|
|
70
|
+
dotAccentColor="#ff7385"
|
|
71
|
+
bindtouchstart={handlePointerDown}
|
|
72
|
+
bindtouchmove={handlePointerMove}
|
|
73
|
+
bindtouchend={handlePointerUp}
|
|
74
|
+
bindlayoutchange={handleElementLayoutChange}
|
|
75
|
+
>
|
|
76
|
+
{models.map((m) => (
|
|
77
|
+
<Dot
|
|
78
|
+
key={m.id}
|
|
79
|
+
x={m.x}
|
|
80
|
+
y={m.y}
|
|
81
|
+
s={m.s}
|
|
82
|
+
color={m.color}
|
|
83
|
+
useAccent={m.useAccent}
|
|
84
|
+
/>
|
|
85
|
+
))}
|
|
86
|
+
</DotField>
|
|
87
|
+
<Caption
|
|
88
|
+
title="Force Field"
|
|
89
|
+
subtitle="Powered by Inline CSS Variables"
|
|
90
|
+
footnote="Requires Lynx SDK 3.6+"
|
|
91
|
+
/>
|
|
92
|
+
</view>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
root.render(<App />);
|
|
97
|
+
|
|
98
|
+
if (import.meta.webpackHot) {
|
|
99
|
+
import.meta.webpackHot.accept();
|
|
100
|
+
}
|
|
@@ -1,34 +1,22 @@
|
|
|
1
|
-
.container {
|
|
2
|
-
width: 100%;
|
|
3
|
-
height: 100%;
|
|
4
|
-
display: flex;
|
|
5
|
-
flex-direction: column;
|
|
6
|
-
justify-content: center;
|
|
7
|
-
align-items: center;
|
|
8
|
-
background-color: black;
|
|
9
|
-
|
|
10
|
-
gap: 48px;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
1
|
.stage {
|
|
14
|
-
width: 100%;
|
|
15
|
-
height: 200px;
|
|
16
2
|
filter: contrast(100);
|
|
17
|
-
|
|
18
3
|
/* background color must be explicit, for contrast */
|
|
19
4
|
background-color: black;
|
|
5
|
+
|
|
6
|
+
width: 100%;
|
|
7
|
+
height: 200px;
|
|
20
8
|
}
|
|
21
9
|
|
|
22
10
|
.inner-stage {
|
|
11
|
+
/* Lynx: only one filter function per declaration */
|
|
12
|
+
filter: blur(20px);
|
|
13
|
+
|
|
23
14
|
width: 100%;
|
|
24
15
|
height: 100%;
|
|
25
16
|
display: flex;
|
|
26
17
|
justify-content: center;
|
|
27
18
|
align-items: center;
|
|
28
19
|
gap: 4px;
|
|
29
|
-
|
|
30
|
-
/* Lynx: only one filter function per declaration */
|
|
31
|
-
filter: blur(20px);
|
|
32
20
|
}
|
|
33
21
|
|
|
34
22
|
.dot {
|
|
@@ -72,28 +60,21 @@
|
|
|
72
60
|
}
|
|
73
61
|
}
|
|
74
62
|
|
|
75
|
-
|
|
63
|
+
/* =========================
|
|
64
|
+
* Layout
|
|
65
|
+
* ========================= */
|
|
66
|
+
|
|
67
|
+
.design-container {
|
|
68
|
+
min-width: 300px;
|
|
69
|
+
width: 100%;
|
|
70
|
+
height: 100%;
|
|
76
71
|
display: flex;
|
|
77
72
|
flex-direction: column;
|
|
73
|
+
justify-content: center;
|
|
78
74
|
align-items: center;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.title {
|
|
82
|
-
font-size: 16px;
|
|
83
|
-
font-weight: 600;
|
|
84
|
-
line-height: 21px;
|
|
85
|
-
color: #f8f8f8;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
.subtitle {
|
|
89
|
-
line-height: 15px;
|
|
90
|
-
font-size: 12px;
|
|
91
|
-
font-weight: 400;
|
|
92
|
-
color: #b3b3b3;
|
|
93
|
-
}
|
|
75
|
+
background-color: black;
|
|
76
|
+
gap: 48px;
|
|
94
77
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
font-size: 11px;
|
|
98
|
-
color: #4f4f4f;
|
|
78
|
+
padding-left: 0;
|
|
79
|
+
padding-right: 0;
|
|
99
80
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { root } from "@lynx-js/react";
|
|
2
2
|
|
|
3
|
+
import { Caption } from "../shared/components/caption/index.jsx";
|
|
3
4
|
import "./index.css";
|
|
4
5
|
|
|
5
6
|
function App() {
|
|
6
7
|
return (
|
|
7
|
-
<view className="container">
|
|
8
|
+
<view className="design-container">
|
|
8
9
|
<view className="stage">
|
|
9
10
|
<view className="inner-stage">
|
|
10
11
|
<view className="dot g1" />
|
|
@@ -13,11 +14,11 @@ function App() {
|
|
|
13
14
|
<view className="dot g4" />
|
|
14
15
|
</view>
|
|
15
16
|
</view>
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
<Caption
|
|
18
|
+
title="Gooey Effect"
|
|
19
|
+
subtitle="Powered by CSS filter: contrast + blur"
|
|
20
|
+
footnote="Requires Lynx SDK 3.6+"
|
|
21
|
+
/>
|
|
21
22
|
</view>
|
|
22
23
|
);
|
|
23
24
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.caption-container {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
align-items: center;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.caption-title {
|
|
8
|
+
font-size: 16px;
|
|
9
|
+
font-weight: 600;
|
|
10
|
+
line-height: 21px;
|
|
11
|
+
color: #f8f8f8;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.caption-subtitle {
|
|
15
|
+
margin-top: 2px;
|
|
16
|
+
font-size: 12px;
|
|
17
|
+
font-weight: 400;
|
|
18
|
+
line-height: 15px;
|
|
19
|
+
color: #b3b3b3;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.caption-footnote {
|
|
23
|
+
margin-top: 4px;
|
|
24
|
+
font-size: 11px;
|
|
25
|
+
line-height: 14px;
|
|
26
|
+
color: #4f4f4f;
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import "./index.css";
|
|
2
|
+
|
|
3
|
+
type CaptionProps = {
|
|
4
|
+
title: string;
|
|
5
|
+
subtitle?: string;
|
|
6
|
+
footnote?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function Caption({ title, subtitle, footnote }: CaptionProps) {
|
|
10
|
+
return (
|
|
11
|
+
<view className="caption-container">
|
|
12
|
+
<text className="caption-title">{title}</text>
|
|
13
|
+
{subtitle && <text className="caption-subtitle">{subtitle}</text>}
|
|
14
|
+
{footnote && <text className="caption-footnote">{footnote}</text>}
|
|
15
|
+
</view>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useRef, useState } from "@lynx-js/react";
|
|
2
|
+
import type { LayoutChangeEvent, TouchEvent } from "@lynx-js/types";
|
|
3
|
+
import { usePointerInteraction } from "../use-pointer-interaction/index.js";
|
|
4
|
+
|
|
5
|
+
type FieldPoint = {
|
|
6
|
+
/** Normalized x in [0, 1] (not clamped). */
|
|
7
|
+
x: number;
|
|
8
|
+
/** Normalized y in [0, 1] (not clamped). */
|
|
9
|
+
y: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type UsePointerFieldPointProps = {
|
|
13
|
+
/** Initial normalized x, defaults to 0.5*/
|
|
14
|
+
x0?: number;
|
|
15
|
+
y0?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function usePointerFieldPoint({ x0 = 0.5, y0 = 0.5 }: UsePointerFieldPointProps = {}) {
|
|
19
|
+
const [p, setP] = useState<FieldPoint>({ x: x0, y: y0 });
|
|
20
|
+
|
|
21
|
+
const eleTopRef = useRef<number | null>(null);
|
|
22
|
+
const eleHeightRef = useRef(0);
|
|
23
|
+
|
|
24
|
+
const lastYRatioRef = useRef(y0);
|
|
25
|
+
|
|
26
|
+
const pointer = usePointerInteraction({
|
|
27
|
+
onUpdate(pos) {
|
|
28
|
+
setP({
|
|
29
|
+
x: pos.offsetRatio,
|
|
30
|
+
y: lastYRatioRef.current,
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const updateYFromEvent = (e: TouchEvent) => {
|
|
36
|
+
const top = eleTopRef.current;
|
|
37
|
+
const height = eleHeightRef.current;
|
|
38
|
+
if (top != null && height > 0) {
|
|
39
|
+
lastYRatioRef.current = (e.detail.y - top) / height;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handlePointerDown2D = (e: TouchEvent) => {
|
|
44
|
+
updateYFromEvent(e);
|
|
45
|
+
pointer.handlePointerDown(e);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handlePointerMove2D = (e: TouchEvent) => {
|
|
49
|
+
updateYFromEvent(e);
|
|
50
|
+
// X handled by existing hook
|
|
51
|
+
pointer.handlePointerMove(e);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleLayoutChange2D = (e: LayoutChangeEvent) => {
|
|
55
|
+
eleHeightRef.current = e.detail.height;
|
|
56
|
+
|
|
57
|
+
const currentTarget = lynx
|
|
58
|
+
.createSelectorQuery()
|
|
59
|
+
// @ts-expect-error
|
|
60
|
+
.selectUniqueID(e.currentTarget.uid);
|
|
61
|
+
|
|
62
|
+
currentTarget
|
|
63
|
+
?.invoke({
|
|
64
|
+
method: "boundingClientRect",
|
|
65
|
+
params: { relativeTo: "screen" },
|
|
66
|
+
success: (res: { top: number }) => {
|
|
67
|
+
eleTopRef.current = res.top;
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
.exec();
|
|
71
|
+
|
|
72
|
+
pointer.handleElementLayoutChange(e);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
p,
|
|
77
|
+
handlePointerDown: handlePointerDown2D,
|
|
78
|
+
handlePointerMove: handlePointerMove2D,
|
|
79
|
+
handlePointerUp: pointer.handlePointerUp,
|
|
80
|
+
handleElementLayoutChange: handleLayoutChange2D,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { usePointerFieldPoint };
|
|
85
|
+
export type { FieldPoint };
|