A knob
The example’s project folder, file by file: components as name.json, shared behaviours as name.behaviour.js. Run it.
fl_volume.json
{
"widget": {
"role": "slider",
"label": "`Volume ${Math.round(s.volume * 100)}%`",
"behaviour": [
{
"use": "controls/drag",
"axis": "dragAxis",
"travel": 300,
"value": "volume",
"steps": "ticks",
"subdivisions": "subdivisions",
"snap": "snap",
"strength": [
"snapMajor",
"snapMinor"
],
"microSnap": "microSnap"
},
{
"use": "controls/wheel",
"value": "volume",
"steps": "ticks",
"subdivisions": "subdivisions",
"snap": "snap",
"strength": [
"snapMajor",
"snapMinor"
],
"microSnap": "microSnap"
}
],
"layers": [
{
"box": [
25,
25,
74,
74
],
"edge": {
"fn": "knurledEdge"
},
"edgeSamples": 360,
"effects": [
{
"blur": 3,
"color": "#000000",
"distance": 1.5,
"opacity": 0.5,
"type": "dropShadow"
},
{
"color": "$ringColor",
"type": "fill"
},
{
"linear": {
"angle": 180,
"relative": true,
"stops": [
[
0,
"#ffffff",
0.55
],
[
0.45,
"#ffffff",
0
],
[
0.55,
"#000000",
0
],
[
1,
"#000000",
0.55
]
]
},
"type": "fill"
},
{
"color": "#161d21",
"opacity": 0.8,
"position": "inside",
"type": "stroke",
"width": 1
}
],
"shape": "circle",
"when": "s.ringStyle < 0.5"
},
{
"box": [
25,
25,
74,
74
],
"edge": {
"fn": "knurledEdge"
},
"edgeSamples": 360,
"effects": [
{
"blur": 4,
"color": "#000000",
"distance": 1.5,
"opacity": 0.25,
"type": "dropShadow"
},
{
"color": "$ringColor",
"type": "fill"
},
{
"linear": {
"angle": 180,
"relative": true,
"stops": [
[
0,
"#000000",
0
],
[
0.6,
"#000000",
0
],
[
1,
"#000000",
0.3
]
]
},
"type": "fill"
},
{
"color": "#000000",
"opacity": 0.45,
"position": "outside",
"type": "stroke",
"width": 0.6
}
],
"opacityExpr": "s.ringOpacity",
"shape": "circle",
"when": "s.ringStyle >= 0.5"
},
{
"box": [
25,
25,
74,
74
],
"edge": {
"fn": "knurledEdge"
},
"edgeSamples": 360,
"effects": [
{
"linear": {
"angle": 180,
"relative": true,
"stops": [
[
0,
"#ffffff",
0.42
],
[
0.22,
"#ffffff",
0.16
],
[
0.4,
"#ffffff",
0.02
],
[
0.5,
"#ffffff",
0
],
[
1,
"#ffffff",
0
]
]
},
"type": "fill"
},
{
"blur": 3,
"color": "#ffffff",
"opacity": 0.18,
"type": "innerGlow"
},
{
"linear": {
"angle": 180,
"relative": true,
"stops": [
[
0,
"#ffffff",
0.85
],
[
0.4,
"#ffffff",
0.1
],
[
1,
"#ffffff",
0.25
]
]
},
"position": "inside",
"type": "stroke",
"width": 0.8
}
],
"opacityExpr": "s.gloss",
"shape": "circle",
"when": "s.ringStyle >= 0.5"
},
{
"effects": [
{
"radial": {
"colors": [
[
"$arcColor",
1
],
[
"$arcColorEnd",
0
]
],
"cx": 0.5,
"cy": 0.5,
"fn": {
"fn": "arcFade"
},
"rx": 0.4032258064516129,
"ry": 0.4032258064516129,
"samples": 64
},
"type": "fill"
}
],
"pathExpr": {
"fn": "valueArc"
},
"shape": "path"
},
{
"effects": [
{
"color": "#e8edf0",
"opacity": 0.85,
"type": "fill"
}
],
"pathExpr": {
"fn": "scaleMarks"
},
"shape": "path"
},
{
"effects": [
{
"blur": 3,
"color": "$accentGlow",
"opacity": 0.7,
"type": "outerGlow"
},
{
"color": "$accent",
"type": "fill"
}
],
"pathExpr": {
"fn": "markUnderValue"
},
"shape": "path",
"when": {
"fn": "onAMark"
}
},
{
"align": "center",
"box": [
0,
0,
24,
10
],
"boxExpr": {
"fn": "minLabelBox"
},
"color": "#c9d1d6",
"font": "Bahnschrift",
"size": 7,
"text": "String(s.minValue)",
"weight": 400
},
{
"align": "center",
"box": [
0,
0,
24,
10
],
"boxExpr": {
"fn": "midLabelBox"
},
"color": "#c9d1d6",
"font": "Bahnschrift",
"size": 7,
"text": "String(+((s.minValue + s.maxValue) / 2).toFixed(2))",
"weight": 400
},
{
"align": "center",
"box": [
0,
0,
24,
10
],
"boxExpr": {
"fn": "maxLabelBox"
},
"color": "#c9d1d6",
"font": "Bahnschrift",
"size": 7,
"text": "String(s.maxValue)",
"weight": 400
},
{
"box": [
34.5,
34.5,
55,
55
],
"effects": [
{
"blur": 2,
"color": "#0b1114",
"distance": 1,
"opacity": 0.9,
"type": "dropShadow"
},
{
"color": "#20292e",
"type": "fill"
},
{
"color": "#20292e",
"position": "outside",
"type": "stroke",
"width": 1
}
],
"shape": "circle"
},
{
"box": [
34.5,
34.5,
55,
55
],
"effects": [
{
"conic": {
"colors": [
"#3a4148",
"#ffffff"
],
"fn": "0.5 + 0.5 * Math.cos(4 * Math.PI * (u - 0.83))",
"relative": true
},
"type": "fill"
},
{
"radial": {
"colors": [
[
"#000000",
0.14
],
[
"#ffffff",
0.12
]
],
"fn": "0.5 + 0.5 * Math.sin(2 * Math.PI * u)",
"repeat": 39,
"rx": 0.5,
"ry": 0.5
},
"type": "fill"
}
],
"shape": "circle",
"when": "s.capStyle < 0.5"
},
{
"box": [
34.5,
34.5,
55,
55
],
"effects": [
{
"color": "$capTint",
"type": "fill"
}
],
"opacityExpr": "s.capTintAmount",
"shape": "circle",
"when": "s.capStyle < 0.5"
},
{
"box": [
34.5,
34.5,
55,
55
],
"effects": [
{
"color": "$capColor",
"type": "fill"
},
{
"linear": {
"angle": 180,
"relative": true,
"stops": [
[
0,
"#000000",
0
],
[
0.5,
"#000000",
0.1
],
[
1,
"#000000",
0.5
]
]
},
"type": "fill"
}
],
"shape": "circle",
"when": "s.capStyle >= 0.5"
},
{
"box": [
34.5,
34.5,
55,
55
],
"effects": [
{
"radial": {
"cx": 0.5,
"cy": 0.5,
"offset": 0.3,
"relative": true,
"rx": 0.7,
"ry": 0.7,
"stops": [
[
0,
"#ffffff",
0.22
],
[
0.5,
"#ffffff",
0.05
],
[
1,
"#ffffff",
0
]
]
},
"type": "fill"
},
{
"linear": {
"angle": 180,
"relative": true,
"stops": [
[
0,
"#ffffff",
0.38
],
[
0.18,
"#ffffff",
0.22
],
[
0.36,
"#ffffff",
0.03
],
[
0.42,
"#ffffff",
0
],
[
1,
"#ffffff",
0
]
]
},
"type": "fill"
},
{
"radial": {
"cx": 0.5,
"cy": 0.5,
"offset": 0.3,
"relative": true,
"rx": 0.16,
"ry": 0.11,
"stops": [
[
0,
"#ffffff",
0.9
],
[
0.35,
"#ffffff",
0.55
],
[
1,
"#ffffff",
0
]
]
},
"type": "fill"
}
],
"opacityExpr": "s.gloss",
"shape": "circle",
"when": "s.capStyle >= 0.5"
},
{
"box": [
34.5,
34.5,
55,
55
],
"effects": [
{
"radial": {
"cx": 0.5,
"cy": 0.5,
"offset": 0.22,
"relative": true,
"rx": 0.62,
"ry": 0.62,
"stops": [
[
0,
"#ffffff",
0.3
],
[
0.55,
"#ffffff",
0.06
],
[
1,
"#ffffff",
0
]
]
},
"type": "fill"
},
{
"radial": {
"cx": 0.5,
"cy": 0.5,
"rx": 0.5,
"ry": 0.5,
"stops": [
[
0,
"#000000",
0
],
[
0.62,
"#000000",
0
],
[
0.88,
"#000000",
0.22
],
[
1,
"#000000",
0.5
]
]
},
"type": "fill"
}
],
"shape": "circle",
"when": "Math.round(s.capProfile) === 1"
},
{
"box": [
34.5,
34.5,
55,
55
],
"effects": [
{
"linear": {
"angle": 180,
"relative": true,
"stops": [
[
0,
"#000000",
0.42
],
[
0.35,
"#000000",
0.08
],
[
0.55,
"#000000",
0
],
[
1,
"#000000",
0
]
]
},
"type": "fill"
},
{
"radial": {
"cx": 0.5,
"cy": 0.5,
"offset": -0.26,
"relative": true,
"rx": 0.6,
"ry": 0.6,
"stops": [
[
0,
"#ffffff",
0.26
],
[
0.5,
"#ffffff",
0.06
],
[
1,
"#ffffff",
0
]
]
},
"type": "fill"
},
{
"blur": 3,
"color": "#000000",
"distance": 1.8,
"opacity": 0.55,
"type": "innerShadow"
}
],
"shape": "circle",
"when": "Math.round(s.capProfile) === 2"
},
{
"box": [
34.5,
34.5,
55,
55
],
"effects": [
{
"blur": 0.8,
"color": "#000000",
"opacity": 0.45,
"spread": 0.3,
"type": "innerShadow"
},
{
"blur": 0.6,
"color": "#000000",
"distance": 0.6,
"opacity": 0.35,
"type": "innerShadow"
},
{
"linear": {
"angle": 180,
"relative": true,
"stops": [
[
0,
"#c6ccd0",
1
],
[
0.35,
"#c6ccd0",
0
],
[
1,
"#c6ccd0",
0
]
]
},
"position": "inside",
"type": "stroke",
"width": 1
}
],
"shape": "circle"
},
{
"effects": [
{
"color": "$accentGlow",
"type": "fill"
},
{
"blur": 0.15,
"color": "#001018",
"distance": 0.7,
"opacity": 0.85,
"type": "innerShadow"
}
],
"pathExpr": {
"fn": "pointerDot"
},
"shape": "path",
"when": "Math.round(s.markStyle) === 0"
},
{
"effects": [
{
"color": "$accent",
"type": "fill"
},
{
"blur": 0.15,
"color": "#001018",
"distance": 0.7,
"opacity": 0.85,
"type": "innerShadow"
},
{
"blur": 0.1,
"color": "#d8fdff",
"distance": -0.45,
"opacity": 0.8,
"type": "innerShadow"
}
],
"opacityExpr": {
"fn": "pointerShine"
},
"pathExpr": {
"fn": "pointerDot"
},
"shape": "path",
"when": "Math.round(s.markStyle) === 0"
},
{
"effects": [
{
"color": "$accentGlow",
"type": "fill"
},
{
"blur": 0.15,
"color": "#001018",
"distance": 0.7,
"opacity": 0.85,
"type": "innerShadow"
}
],
"pathExpr": {
"fn": "pointerLine"
},
"shape": "path",
"when": "Math.round(s.markStyle) === 1"
},
{
"effects": [
{
"color": "$accent",
"type": "fill"
},
{
"blur": 0.15,
"color": "#001018",
"distance": 0.7,
"opacity": 0.85,
"type": "innerShadow"
},
{
"blur": 0.1,
"color": "#d8fdff",
"distance": -0.45,
"opacity": 0.8,
"type": "innerShadow"
}
],
"opacityExpr": {
"fn": "pointerShine"
},
"pathExpr": {
"fn": "pointerLine"
},
"shape": "path",
"when": "Math.round(s.markStyle) === 1"
},
{
"effects": [
{
"color": "$accentGlow",
"type": "fill"
},
{
"blur": 0.15,
"color": "#001018",
"distance": 0.7,
"opacity": 0.85,
"type": "innerShadow"
}
],
"pathExpr": {
"fn": "pointerTip"
},
"shape": "path",
"when": "Math.round(s.markStyle) === 2"
},
{
"effects": [
{
"color": "$accent",
"type": "fill"
},
{
"blur": 0.15,
"color": "#001018",
"distance": 0.7,
"opacity": 0.85,
"type": "innerShadow"
},
{
"blur": 0.1,
"color": "#d8fdff",
"distance": -0.45,
"opacity": 0.8,
"type": "innerShadow"
}
],
"opacityExpr": {
"fn": "pointerShine"
},
"pathExpr": {
"fn": "pointerTip"
},
"shape": "path",
"when": "Math.round(s.markStyle) === 2"
}
],
"light": "s.light",
"motion": {
"k": {
"duration": 280,
"ease": "easeOut",
"to": "ui.hover ? 1 : 0"
},
"v": {
"duration": 260,
"ease": "spring",
"to": "s.volume"
},
"ve": {
"duration": 50,
"ease": "easeOut",
"to": "s.volume"
}
},
"params": {
"accent": {
"label": "Accent",
"type": "color"
},
"accentGlow": {
"label": "Accent glow",
"type": "color"
},
"arcColor": {
"label": "Arc colour",
"type": "color"
},
"arcColorEnd": {
"label": "Arc fades to",
"type": "color"
},
"arcFill": {
"choices": [
"Fading",
"Solid"
]
},
"arcRound": {
"label": "Arc end roundness",
"max": 1,
"min": 0,
"step": 0.05
},
"arcWidth": {
"label": "Arc thickness",
"max": 10,
"min": 0.5,
"step": 0.1
},
"capColor": {
"label": "Cap colour (plastic)",
"type": "color"
},
"capProfile": {
"choices": [
"Flat",
"Domed",
"Dished"
]
},
"capStyle": {
"choices": [
"Brushed",
"Plain"
]
},
"capTint": {
"label": "Cap tint (metal)",
"type": "color"
},
"capTintAmount": {
"label": "Cap tint amount",
"max": 0.8,
"min": 0,
"step": 0.01
},
"fadePower": {
"label": "Fade softness",
"max": 4,
"min": 0.5,
"step": 0.05
},
"fadeStart": {
"label": "Fade start",
"max": 0.9,
"min": 0,
"step": 0.01
},
"gloss": {
"label": "Glossiness",
"max": 1,
"min": 0,
"step": 0.01
},
"knurlDepth": {
"label": "Knurl depth",
"max": 10,
"min": 0,
"step": 0.1
},
"knurlRound": {
"label": "Knurl roundness",
"max": 1,
"min": 0,
"step": 0.05
},
"knurls": {
"label": "Knurls",
"max": 12,
"min": 0,
"step": 1
},
"light": {
"label": "Light angle",
"max": 359,
"min": 0,
"step": 1,
"unit": "deg"
},
"longEvery": {
"label": "Long mark every",
"max": 10,
"min": 1,
"step": 1
},
"markRadius": {
"label": "Marks distance",
"max": 56,
"min": 38,
"step": 0.1
},
"markStyle": {
"choices": [
"Dot",
"Line",
"Triangle"
]
},
"maxValue": {
"label": "End value",
"max": 1000,
"min": 0,
"step": 1
},
"minValue": {
"label": "Start value",
"max": 100,
"min": -100,
"step": 1
},
"ringColor": {
"label": "Ring colour",
"type": "color"
},
"ringOpacity": {
"label": "Glass opacity",
"max": 1,
"min": 0.1,
"step": 0.01
},
"ringStyle": {
"choices": [
"Solid",
"Glass"
]
},
"snap": {
"choices": [
"Off",
"On"
]
},
"snapMajor": {
"label": "Snap strength, majors",
"max": 1,
"min": 0,
"step": 0.05
},
"snapMinor": {
"label": "Snap strength, subdivisions",
"max": 1,
"min": 0,
"step": 0.05
},
"subdivisions": {
"label": "Subdivisions",
"max": 4,
"min": 0,
"step": 1
},
"ticks": {
"label": "Major marks",
"max": 40,
"min": 2,
"step": 1
},
"volume": {
"label": "Volume",
"max": 1,
"min": 0,
"step": 0.01
},
"microSnap": {
"min": 0,
"max": 0.45
},
"markLit": {
"min": 0,
"max": 0.5
}
},
"size": [
124,
124
],
"state": {
"accent": "#67f2ff",
"accentGlow": "#1ec8ff",
"arcColor": "#67f2ff",
"arcColorEnd": "#1ec8ff",
"arcFill": 0,
"arcRound": 1,
"arcWidth": 2.1,
"capColor": "#2a2e33",
"capProfile": 0,
"capStyle": 0,
"capTint": "#3a7bd5",
"capTintAmount": 0,
"fadePower": 2,
"fadeStart": 0,
"gloss": 1,
"knurlDepth": 6,
"knurlRound": 1,
"knurls": 5,
"light": 270,
"longEvery": 5,
"markRadius": 40.5,
"markStyle": 0,
"maxValue": 100,
"minValue": 0,
"ringColor": "#4a555b",
"ringOpacity": 0.55,
"ringStyle": 0,
"snap": 1,
"snapMajor": 1,
"snapMinor": 1,
"subdivisions": 1,
"ticks": 21,
"volume": 0.8,
"microSnap": 0.06,
"markLit": 0.25,
"dragAxis": "auto"
}
}
}fl_volume.js
/** fl_volume's code: the functions fl_volume.json refers to by name, and its behaviour. */
export function knurledEdge(t, a, x, y, s, m) {
return !m.k || !s.knurlDepth || !s.knurls
? 0
: -m.k *
s.knurlDepth *
((f) => s.knurlRound * (1 - Math.sin(Math.PI * f)) + (1 - s.knurlRound) * Math.abs(2 * f - 1))(
(((((a -
((120 + 300 * Math.min(1, Math.max(0, (s.snap ? m.v : m.ve) ?? s.volume)) + 90) * Math.PI) /
180) *
s.knurls) /
(2 * Math.PI) +
0.5) %
1) +
1) %
1,
);
}
export function valueArc(s, ui, m) {
const c = 62,
r0 = 37.4,
r1 = 37.4 + s.arcWidth,
rm = (r0 + r1) / 2,
h = (r1 - r0) / 2,
rnd = Math.min(1, Math.max(0, s.arcRound)),
dc = (((rnd * h) / rm) * 180) / Math.PI;
const a0 = 120 + dc,
a1 = 120 + 300 * Math.min(1, Math.max(0, (s.snap ? m.v : m.ve) ?? s.volume)) - dc;
if (a1 <= a0) return "";
const K = (th) => {
if (!m.k || !s.knurlDepth || !s.knurls) return 0;
const u =
((th - (120 + 300 * Math.min(1, Math.max(0, (s.snap ? m.v : m.ve) ?? s.volume)))) * s.knurls) / 360 +
0.5,
f = ((u % 1) + 1) % 1;
return (
m.k *
s.knurlDepth *
((f) => s.knurlRound * (1 - Math.sin(Math.PI * f)) + (1 - s.knurlRound) * Math.abs(2 * f - 1))(f)
);
};
const f = (v) => v.toFixed(3);
const P = (r, a) => {
const q = r - K(a);
return f(c + q * Math.cos((a * Math.PI) / 180)) + " " + f(c + q * Math.sin((a * Math.PI) / 180));
};
const cap = (a, s) => {
const t = (a * Math.PI) / 180,
R = [Math.cos(t), Math.sin(t)],
T = [-Math.sin(t), Math.cos(t)];
const k = K(a),
cx = c + (rm - k) * R[0],
cy = c + (rm - k) * R[1];
let o = "";
for (let k = 1; k < 12; ++k) {
const q = (Math.PI * k) / 12,
u = s * Math.cos(q),
v = s * Math.sin(q) * rnd;
o += "L" + f(cx + h * (R[0] * u + T[0] * v)) + " " + f(cy + h * (R[1] * u + T[1] * v));
}
return o;
};
const n = Math.max(2, Math.ceil((a1 - a0) / 2));
let d = "M" + P(r1, a0);
for (let i = 1; i <= n; ++i) d += "L" + P(r1, a0 + ((a1 - a0) * i) / n);
d += cap(a1, 1);
for (let i = n; i >= 0; --i) d += "L" + P(r0, a0 + ((a1 - a0) * i) / n);
d += cap(a0, -1);
return d + "Z";
}
export function arcFade(u, s) {
return s.arcFill >= 0.5
? 0
: ((r) => {
const t = Math.min(1, Math.max(0, (r - 37.4) / s.arcWidth));
return t < s.fadeStart ? 0 : 1 - Math.pow(1 - (t - s.fadeStart) / (1 - s.fadeStart), s.fadePower);
})(u * 50);
}
export function scaleMarks(s, ui, m) {
{
const c = 62,
sub = Math.max(0, Math.round(s.subdivisions)),
per = sub + 1,
n = Math.max(2, Math.round(s.ticks)),
m = (n - 1) * per,
le = Math.max(1, Math.round(s.longEvery)),
r0 = s.markRadius,
kind = (i) => (i % per !== 0 ? 0 : (i / per) % le === 0 ? 2 : 1),
len = (k) => (k === 2 ? 7.5 : k === 1 ? 5 : 2.5),
wid = (k) => (k === 0 ? 0.7 : 1.6),
bar = (i, k) => {
const a = ((120 + (300 * i) / m) * Math.PI) / 180,
ca = Math.cos(a),
sa = Math.sin(a),
h = wid(k) / 2,
R1 = r0 + len(k);
const p = (r, o) => (c + r * ca - o * sa).toFixed(3) + " " + (c + r * sa + o * ca).toFixed(3);
return "M" + p(r0, -h) + "L" + p(R1, -h) + "L" + p(R1, h) + "L" + p(r0, h) + "Z";
};
let d = "";
for (let i = 0; i <= m; ++i) d += bar(i, kind(i));
return d;
}
}
export function onAMark(s, ui, m) {
{
const c = 62,
sub = Math.max(0, Math.round(s.subdivisions)),
per = sub + 1,
n = Math.max(2, Math.round(s.ticks)),
m = (n - 1) * per,
le = Math.max(1, Math.round(s.longEvery)),
r0 = s.markRadius,
kind = (i) => (i % per !== 0 ? 0 : (i / per) % le === 0 ? 2 : 1),
len = (k) => (k === 2 ? 7.5 : k === 1 ? 5 : 2.5),
wid = (k) => (k === 0 ? 0.7 : 1.6),
bar = (i, k) => {
const a = ((120 + (300 * i) / m) * Math.PI) / 180,
ca = Math.cos(a),
sa = Math.sin(a),
h = wid(k) / 2,
R1 = r0 + len(k);
const p = (r, o) => (c + r * ca - o * sa).toFixed(3) + " " + (c + r * sa + o * ca).toFixed(3);
return "M" + p(r0, -h) + "L" + p(R1, -h) + "L" + p(R1, h) + "L" + p(r0, h) + "Z";
};
const x = Math.min(1, Math.max(0, (s.snap ? m.v : m.ve) ?? s.volume)) * m;
return Math.abs(x - Math.round(x)) < (s.snap ? 0.02 : (s.markLit ?? 0.25));
}
}
export function markUnderValue(s, ui, m) {
{
const c = 62,
sub = Math.max(0, Math.round(s.subdivisions)),
per = sub + 1,
n = Math.max(2, Math.round(s.ticks)),
m = (n - 1) * per,
le = Math.max(1, Math.round(s.longEvery)),
r0 = s.markRadius,
kind = (i) => (i % per !== 0 ? 0 : (i / per) % le === 0 ? 2 : 1),
len = (k) => (k === 2 ? 7.5 : k === 1 ? 5 : 2.5),
wid = (k) => (k === 0 ? 0.7 : 1.6),
bar = (i, k) => {
const a = ((120 + (300 * i) / m) * Math.PI) / 180,
ca = Math.cos(a),
sa = Math.sin(a),
h = wid(k) / 2,
R1 = r0 + len(k);
const p = (r, o) => (c + r * ca - o * sa).toFixed(3) + " " + (c + r * sa + o * ca).toFixed(3);
return "M" + p(r0, -h) + "L" + p(R1, -h) + "L" + p(R1, h) + "L" + p(r0, h) + "Z";
};
const i = Math.round(Math.min(1, Math.max(0, (s.snap ? m.v : m.ve) ?? s.volume)) * m);
return bar(i, kind(i));
}
}
export function minLabelBox(s, ui, m) {
const a = ((120 + 300 * 0) * Math.PI) / 180,
r = s.markRadius + 14;
return [62 + r * Math.cos(a) - 12, 62 + r * Math.sin(a) - 5, 24, 10];
}
export function midLabelBox(s, ui, m) {
const a = ((120 + 300 * 0.5) * Math.PI) / 180,
r = s.markRadius + 14;
return [62 + r * Math.cos(a) - 12, 62 + r * Math.sin(a) - 5, 24, 10];
}
export function maxLabelBox(s, ui, m) {
const a = ((120 + 300 * 1) * Math.PI) / 180,
r = s.markRadius + 14;
return [62 + r * Math.cos(a) - 12, 62 + r * Math.sin(a) - 5, 24, 10];
}
export function pointerDot(s, ui, m) {
const t = ((120 + 300 * Math.min(1, Math.max(0, (s.snap ? m.v : m.ve) ?? s.volume))) * Math.PI) / 180,
x = 62 + 32.3 * Math.cos(t),
y = 62 + 32.3 * Math.sin(t),
q = 1.9;
let d = "";
for (let i = 0; i < 24; ++i) {
const u = (i / 24) * 2 * Math.PI;
d += (i ? "L" : "M") + (x + q * Math.cos(u)).toFixed(3) + " " + (y + q * Math.sin(u)).toFixed(3);
}
return d + "Z";
}
export function pointerShine(s, ui, m) {
return (
0.5 +
0.5 *
Math.cos(
((120 + 300 * Math.min(1, Math.max(0, (s.snap ? m.v : m.ve) ?? s.volume)) - m.light) * Math.PI) / 180,
)
);
}
export function pointerLine(s, ui, m) {
const t = ((120 + 300 * Math.min(1, Math.max(0, (s.snap ? m.v : m.ve) ?? s.volume))) * Math.PI) / 180,
c = Math.cos(t),
s2 = Math.sin(t),
h = 1.5 / 2,
k = 62;
const p = (r, o) => (k + r * c - o * s2).toFixed(3) + " " + (k + r * s2 + o * c).toFixed(3);
return "M" + p(29, -h) + "L" + p(35.5, -h) + "L" + p(35.5, h) + "L" + p(29, h) + "Z";
}
export function pointerTip(s, ui, m) {
const t = ((120 + 300 * Math.min(1, Math.max(0, (s.snap ? m.v : m.ve) ?? s.volume))) * Math.PI) / 180,
ca = Math.cos(t),
sa = Math.sin(t),
c = 62,
q = 2.3,
r = 32.3;
const p = (rr, o) => (c + rr * ca - o * sa).toFixed(3) + " " + (c + rr * sa + o * ca).toFixed(3);
return "M" + p(r + q, 0) + "L" + p(r - q * 0.8, -q * 0.95) + "L" + p(r - q * 0.8, q * 0.95) + "Z";
}controls/detents.js
From the controls library, shared by the examples.
/**
* Marks a value (0 to 1) can rest on, shared by the drag and wheel behaviours: majors (`steps` of them) with
* `subdivisions` between, each level pulling with its own `strength`; `snap` off leaves the value free, held on a mark
* only across `microSnap`. Each setting is a number, or the name of a state key that holds it.
*/
export function detents(o) {
// a setting: its number, or the state's value under the name it gives
const at = (s, k, d) => {
const v = o[k];
return v == null ? d : typeof v === "string" ? (s[v] ?? d) : v;
};
const key = o.value || "value";
const clamp = (v) => Math.min(1, Math.max(0, v));
// marks a major spans (its subdivisions and itself), and marks across the range
const per = (s) => Math.max(0, Math.round(at(s, "subdivisions", 0))) + 1;
const steps = (s) => (Math.max(2, Math.round(at(s, "steps", 11))) - 1) * per(s);
const pull = (s, level) => {
const v = (o.strength || [1, 1])[level];
return clamp(typeof v === "string" ? (s[v] ?? 0) : (v ?? 0));
};
const snaps = (s) => !!at(s, "snap", 1);
/** The value as felt while it moves: pulled toward the nearest mark (continuous, never backwards). */
const detent = (v, s) => {
const n = steps(s);
const h = Math.round(v * n);
const d = v * n - h;
if (!snaps(s)) {
// snap off: no pull; a mark held only across a hair, that much taken out of the travel, so there is no jump
const z = Math.min(0.45, Math.max(0, at(s, "microSnap", 0)));
if (!z) return v;
const a = Math.abs(d);
return (h + (a <= z ? 0 : ((Math.sign(d) * (a - z)) / (0.5 - z)) * 0.5)) / n;
}
const k = h % per(s) === 0 ? pull(s, 0) : pull(s, 1);
return (h + d - (k * Math.sin(2 * Math.PI * d)) / (2 * Math.PI)) / n;
};
/** Where it settles: the closest mark of a level that snaps (subdivisions, else majors), else where it is. */
const nearest = (v, s) => {
if (!snaps(s)) return v;
const p = per(s);
const n = steps(s);
if (pull(s, 1) > 0 && p > 1) return Math.round(v * n) / n;
if (pull(s, 0) > 0) return (Math.round((v * n) / p) * p) / n;
return v;
};
/** One step between marks that snap, else a fiftieth of the range. */
const step = (s) => {
const minor = pull(s, 1) > 0 && per(s) > 1;
return snaps(s) && (minor || pull(s, 0) > 0) ? (minor ? 1 : per(s)) / steps(s) : 1 / 50;
};
return { at, key, clamp, detent, nearest, step };
}controls/drag.behaviour.js
From the controls library, shared by the examples.
/**
* A value dragged by the pointer, from anywhere on the widget, resting on marks (see detents.js). `axis`:
* - "y": up is more; "x": right is more; "both": up and right both count;
* - "angle": the value follows the pointer around the widget's centre, clockwise is more, `sweep` degrees (300) for
* the whole range: for those who take a knob by its mark and turn it;
* - "auto": the knob follows the pointer ("angle") wherever it is taken - the knurl, the disk, the arc, the marks -
* as a real knob would; a press at its very centre (within `centre`, a share of the width: 0.15), where an angle
* means nothing, drags straight ("both");
* - or the name of a state key holding one of those.
* `travel` (for the straight axes): the pixels for the whole range, a state key's, or "width" / "height" (its own,
* less `inset`). Let go, it settles on the nearest mark that snaps.
*/
import { detents } from "./detents.js";
const AXES = ["x", "y", "both", "angle", "auto"];
export default (o) => {
const d = detents(o);
const axisOf = (s) => {
const a = o.axis ?? "y";
return AXES.includes(a) ? a : AXES.includes(s[a]) ? s[a] : "y";
};
const travel = (s, ui) =>
o.travel === "width" ? (ui?.w ?? 300) - (o.inset ?? 0)
: o.travel === "height" ? (ui?.h ?? 300) - (o.inset ?? 0)
: d.at(s, "travel", 300);
// The turn so far, as a share of the range, added up move by move: each move's angle around the centre against the
// last one's, so a turn past half a circle never wraps back. Kept in state while the drag lasts (_turn, _turnAt,
// _turnFrom: the press's angle, which tells a new drag from the one going on), and held within the range, so turning
// back from a stop moves at once.
const turned = (s0, dx, dy, ui, s) => {
if (ui?.x == null || ui?.y == null) return {};
const cx = ui.w / 2, cy = ui.h / 2;
const from = Math.atan2(ui.y - dy - cy, ui.x - dx - cx);
const now = Math.atan2(ui.y - cy, ui.x - cx);
const same = s && s._turnFrom === from;
let step = now - (same ? s._turnAt : from);
if (step > Math.PI) step -= 2 * Math.PI;
if (step < -Math.PI) step += 2 * Math.PI;
const sweep = ((o.sweep ?? 300) * Math.PI) / 180;
const v0 = d.clamp(s0[d.key] ?? 0);
const turn = Math.min((1 - v0) * sweep, Math.max(-v0 * sweep, (same ? s._turn : 0) + step));
return { share: turn / sweep, keep: { _turn: turn, _turnAt: now, _turnFrom: from } };
};
// auto: taken anywhere but its very centre, it follows the pointer round
const offCentre = (dx, dy, ui) => ui?.x != null && Math.hypot(ui.x - dx - ui.w / 2, ui.y - dy - ui.h / 2) > (o.centre ?? 0.15) * ui.w;
// how far the pointer moved along the axes this drag reads (up and right are more), and what to keep meanwhile
const along = (s0, dx, dy, ui, s, axis) => {
if (axis === "auto") axis = offCentre(dx, dy, ui) ? "angle" : "both";
if (axis === "angle") return turned(s0, dx, dy, ui, s);
return { share: (axis === "x" ? dx : axis === "both" ? dx - dy : -dy) / Math.max(1, travel(s0, ui)) };
};
return {
drag(s0, dx, dy, ui, s) {
const { share = 0, keep = {} } = along(s0, dx, dy, ui, s, axisOf(s0));
const v = d.clamp(s0[d.key] ?? 0) + share;
return { [d.key]: d.clamp(d.detent(d.clamp(v), s0)), ...keep };
},
release(s) {
return { [d.key]: d.clamp(d.nearest(s[d.key] ?? 0, s)) };
},
};
};controls/wheel.behaviour.js
From the controls library, shared by the examples.
/**
* A value turned by the mouse wheel (or a watch's crown, which falls back to the wheel): a mark per notch when its
* marks snap, else a fiftieth of the range (see detents.js). `reverse`: true turns it the other way.
*/
import { detents } from "./detents.js";
export default (o) => {
const d = detents(o);
return {
wheel(s, notches) {
const n = (o.reverse ? -1 : 1) * Math.sign(notches) * Math.max(1, Math.round(Math.abs(notches)));
return { [d.key]: d.clamp(d.nearest((s[d.key] ?? 0) + n * d.step(s), s)) };
},
};
};