@crievo/omni 0.1.3 → 0.1.4
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/package.json +2 -1
- package/reference/learnings.md +12 -0
- package/studio/api/app.ts +2 -2
- package/studio/api/routes/sound.ts +22 -3
- package/studio/api/services/sound.ts +45 -0
- package/studio/studio.ts +1 -0
- package/studio/web/components/AppShell.tsx +3 -1
- package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
- package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
- package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
- package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
- package/studio/web/components/Omni/OmniWidget.tsx +14 -5
- package/studio/web/components/Omni/PendingActions.tsx +25 -6
- package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
- package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
- package/studio/web/components/ResultMessage.tsx +12 -3
- package/studio/web/components/Sidebar.tsx +63 -24
- package/studio/web/components/StageStepper.tsx +105 -34
- package/studio/web/components/Toaster.tsx +25 -0
- package/studio/web/components/VoiceCarousel.tsx +19 -16
- package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
- package/studio/web/components/bencho/ActionNode.tsx +456 -0
- package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
- package/studio/web/components/bencho/BenchoScope.tsx +8 -0
- package/studio/web/components/bencho/Checklist.tsx +421 -0
- package/studio/web/components/bencho/CreateMenu.tsx +258 -0
- package/studio/web/components/bencho/GenerateImage.tsx +290 -0
- package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
- package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
- package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
- package/studio/web/components/bencho/NowPlaying.tsx +403 -0
- package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
- package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
- package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
- package/studio/web/components/bencho/SloshSlider.tsx +394 -0
- package/studio/web/components/bencho/StackedBars.tsx +342 -0
- package/studio/web/components/bencho/ToastStack.tsx +263 -0
- package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
- package/studio/web/components/bencho/VoiceNote.tsx +705 -0
- package/studio/web/components/icons.tsx +27 -0
- package/studio/web/components/ui/slider.tsx +34 -1
- package/studio/web/components/ui/switch.tsx +18 -5
- package/studio/web/index.html +1 -1
- package/studio/web/lib/omni-panel.ts +4 -1
- package/studio/web/lib/toast.ts +196 -0
- package/studio/web/lib/upload.ts +24 -0
- package/studio/web/pages/BenchoPage/index.tsx +74 -0
- package/studio/web/pages/BenchoPage/registry.ts +174 -0
- package/studio/web/pages/NewVideoPage/index.tsx +6 -2
- package/studio/web/pages/ReferencesPage/index.tsx +4 -1
- package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
- package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
- package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
- package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
- package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
- package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
- package/studio/web/pages/VideoPage/index.tsx +4 -0
- package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/bencho/action-node.css +227 -0
- package/studio/web/styles/components/bencho/checklist.css +182 -0
- package/studio/web/styles/components/bencho/dock.css +188 -0
- package/studio/web/styles/components/bencho/gallery.css +109 -0
- package/studio/web/styles/components/bencho/generate.css +151 -0
- package/studio/web/styles/components/bencho/image-accordion.css +37 -0
- package/studio/web/styles/components/bencho/liq-create.css +279 -0
- package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
- package/studio/web/styles/components/bencho/one-time-code.css +148 -0
- package/studio/web/styles/components/bencho/picker.css +204 -0
- package/studio/web/styles/components/bencho/pull.css +313 -0
- package/studio/web/styles/components/bencho/scope.css +42 -0
- package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
- package/studio/web/styles/components/bencho/slosh.css +137 -0
- package/studio/web/styles/components/bencho/sound.css +260 -0
- package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
- package/studio/web/styles/components/bencho/toast-stack.css +192 -0
- package/studio/web/styles/components/bencho/tokens.css +243 -0
- package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
- package/studio/web/styles/components/bencho/voice-note.css +337 -0
- package/studio/web/styles/components/clone-voice.css +12 -2
- package/studio/web/styles/components/omni.css +6 -1
- package/studio/web/styles/components/stepper.css +123 -7
- package/studio/web/styles/components/toaster.css +10 -0
- package/studio/web/styles/components/ui/slider.css +6 -16
- package/studio/web/styles/components/ui/switch.css +2 -41
- package/studio/web/styles/components/voice-carousel.css +46 -4
- package/studio/web/styles/index.css +23 -0
- package/studio/web/styles/pages/video/layout.css +1 -4
- package/studio/web/styles/pages/video/sound-timeline.css +8 -0
- package/studio/web/styles/theme/identity/shell.css +10 -26
- package/tools/sound.mjs +67 -3
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/* A portfolio card. Pull it down and it stretches; let go past the line and it refreshes. Run along the chart to read
|
|
2
|
+
the day back. Ported from bencho.dev (MIT).
|
|
3
|
+
|
|
4
|
+
Elasticity is feedback, never latency: every outcome has already happened by the time anything settles. The
|
|
5
|
+
refresh fires on release, not when the ring stops turning, and the new figure lands with the spring back.
|
|
6
|
+
The commitment happens at a distance, not on release: crossing the threshold arms the refresh while the finger is
|
|
7
|
+
still down, so you know it will happen. Deciding on release would hide the only information the gesture carries. */
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type CSSProperties,
|
|
11
|
+
type PointerEvent as ReactPointerEvent,
|
|
12
|
+
useEffect,
|
|
13
|
+
useLayoutEffect,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from "react";
|
|
17
|
+
|
|
18
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
19
|
+
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
20
|
+
|
|
21
|
+
/* Screen pixels are not layout pixels: the stage can scale the card. Every drag divides by this before it believes
|
|
22
|
+
a delta. */
|
|
23
|
+
const zoomOf = (el: HTMLElement) => el.getBoundingClientRect().width / (el.offsetWidth || 1) || 1;
|
|
24
|
+
|
|
25
|
+
/* One spring for everything that settles: it accumulates velocity toward the target, damps it, and snaps when both
|
|
26
|
+
the delta and the velocity fall under 0.02. `dt` is in sixtieths of a second and the damping is raised to it
|
|
27
|
+
rather than multiplied by it, so a dropped frame decays the same energy as the two frames it replaced. The loop
|
|
28
|
+
parks itself the moment the value settles.
|
|
29
|
+
The pair is chosen by damping ratio, zeta = -ln(d) / (2 * sqrt(k)): 0 is heavy and arrives without a ring
|
|
30
|
+
(zeta ~0.85), 50 sits near 0.41, 100 is lively with two visible rebounds (~0.20). */
|
|
31
|
+
const springOf = (tune: number) => ({
|
|
32
|
+
/* stiffness: how hard it is pulled toward the target */
|
|
33
|
+
k: 0.08 + (tune / 100) * 0.16,
|
|
34
|
+
/* decay, per frame: how much of the velocity survives */
|
|
35
|
+
d: 0.62 + (tune / 100) * 0.2,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/* The snap threshold is absolute, so the caller works in pixels. */
|
|
39
|
+
function useSpring(target: number, tune = 50, instant = false) {
|
|
40
|
+
const [at, setAt] = useState(target);
|
|
41
|
+
const cur = useRef(target);
|
|
42
|
+
const vel = useRef(0);
|
|
43
|
+
const raf = useRef(0);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (instant) {
|
|
47
|
+
cur.current = target;
|
|
48
|
+
vel.current = 0;
|
|
49
|
+
setAt(target);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const { k, d } = springOf(tune);
|
|
53
|
+
let prev = 0;
|
|
54
|
+
const tick = (t: number) => {
|
|
55
|
+
const dt = prev ? clamp((t - prev) / 16.67, 0, 2.5) : 1;
|
|
56
|
+
prev = t;
|
|
57
|
+
vel.current += (target - cur.current) * k * dt;
|
|
58
|
+
vel.current *= d ** dt;
|
|
59
|
+
cur.current += vel.current * dt;
|
|
60
|
+
if (Math.abs(target - cur.current) < 0.02 && Math.abs(vel.current) < 0.02) {
|
|
61
|
+
cur.current = target;
|
|
62
|
+
vel.current = 0;
|
|
63
|
+
setAt(target);
|
|
64
|
+
raf.current = 0;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setAt(cur.current);
|
|
68
|
+
raf.current = requestAnimationFrame(tick);
|
|
69
|
+
};
|
|
70
|
+
raf.current = requestAnimationFrame(tick);
|
|
71
|
+
return () => {
|
|
72
|
+
cancelAnimationFrame(raf.current);
|
|
73
|
+
raf.current = 0;
|
|
74
|
+
};
|
|
75
|
+
/* `tune` sits beside `target` because the loop closes over it; restarting picks up from the refs, so it
|
|
76
|
+
continues rather than snapping. */
|
|
77
|
+
}, [target, tune, instant]);
|
|
78
|
+
|
|
79
|
+
return at;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Read once: a preference, not a live input. */
|
|
83
|
+
const stillness = () =>
|
|
84
|
+
typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
85
|
+
|
|
86
|
+
/* The stage scales the card, and the scale lands on the card's own coordinate space; this reads it back. */
|
|
87
|
+
function useGooScale() {
|
|
88
|
+
const box = useRef<HTMLDivElement | null>(null);
|
|
89
|
+
const [k, setK] = useState(1);
|
|
90
|
+
useLayoutEffect(() => {
|
|
91
|
+
const el = box.current;
|
|
92
|
+
if (!el) return;
|
|
93
|
+
const read = () => {
|
|
94
|
+
const r = el.getBoundingClientRect();
|
|
95
|
+
const next = r.width / (el.offsetWidth || r.width) || 1;
|
|
96
|
+
setK(was => (Math.abs(was - next) < 0.001 ? was : next));
|
|
97
|
+
};
|
|
98
|
+
read();
|
|
99
|
+
const ro = new ResizeObserver(read);
|
|
100
|
+
ro.observe(el);
|
|
101
|
+
/* a transform change is not a resize */
|
|
102
|
+
const t = window.setInterval(read, 500);
|
|
103
|
+
return () => {
|
|
104
|
+
ro.disconnect();
|
|
105
|
+
window.clearInterval(t);
|
|
106
|
+
};
|
|
107
|
+
}, []);
|
|
108
|
+
return { box, k };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Three fixed readings, cycled in order, each starting where the last one ended: a refresh continues the day rather
|
|
112
|
+
than resetting it. One array is the whole truth: the balance is the last point, the change is last minus first,
|
|
113
|
+
the percentage is that over the first. */
|
|
114
|
+
const READS: number[][] = [
|
|
115
|
+
[
|
|
116
|
+
57630.15, 57672.02, 57697.31, 57740.67, 57829.15, 57881.9, 57875.25, 57871.44, 57841.43, 57824.68, 57957.03,
|
|
117
|
+
58078.61, 58095.22, 58136.25, 58156.95, 58113.65, 58180.3, 58394.72, 58518.55, 58536.23, 58450.89, 58354.12,
|
|
118
|
+
58489.6, 58573.06, 58508.69, 58536.64, 58594.77, 58647.64, 58668.57, 58682.61, 58720.06, 58743.78, 58784.11,
|
|
119
|
+
58834.75,
|
|
120
|
+
],
|
|
121
|
+
[
|
|
122
|
+
58834.75, 58842.84, 58858.72, 58877.48, 58874.75, 58872.61, 58896.56, 58906.48, 58893.08, 58922.63, 58981.41,
|
|
123
|
+
58963.18, 58940.77, 59001.24, 59035.63, 59026.34, 59052.16, 59073.47, 59033.25, 59010.92, 58996.54, 58935.27,
|
|
124
|
+
58911.5, 58975.07, 59037.9, 59022.02, 59004.8, 59038.44, 59055.78, 59056.52, 59072.15, 59078.52, 59088.28, 59102.4,
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
59102.4, 59086.9, 59077.06, 59068.52, 59055.95, 59019.04, 58999.47, 58975.1, 58903.86, 58916.98, 58989.57, 58926.11,
|
|
128
|
+
58763.26, 58683.68, 58727.54, 58778.0, 58758.83, 58718.66, 58682.76, 58709.66, 58792.35, 58782.87, 58692.33,
|
|
129
|
+
58598.58, 58598.77, 58672.38, 58655.19, 58615.97, 58621.85, 58624.03, 58595.53, 58549.14, 58523.68, 58516.05,
|
|
130
|
+
],
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/* Tabular figures: the same advance for every digit, so the number can change on a refresh or under a scrub without
|
|
134
|
+
anything after it moving. */
|
|
135
|
+
const money = (n: number) => n.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
136
|
+
|
|
137
|
+
/* the cents ride at a lighter weight, so split them off */
|
|
138
|
+
const split = (n: number): [string, string] => {
|
|
139
|
+
const s = money(n);
|
|
140
|
+
const comma = s.lastIndexOf(",");
|
|
141
|
+
return [s.slice(0, comma), s.slice(comma)];
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const signed = (n: number) => (n < 0 ? "−" : "+") + money(Math.abs(n));
|
|
145
|
+
const percent = (n: number) => n.toLocaleString("pt-BR", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
|
146
|
+
|
|
147
|
+
/* Thirty-four readings across a trading day. The scrub says when, because a number with no time on it is just a
|
|
148
|
+
different number. */
|
|
149
|
+
const OPEN = 9 * 60;
|
|
150
|
+
const SHUT = 17 * 60 + 30;
|
|
151
|
+
const clock = (i: number, n: number) => {
|
|
152
|
+
const m = Math.round(OPEN + ((SHUT - OPEN) * i) / (n - 1));
|
|
153
|
+
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/* Six is where a ring still reads as a circle rather than a polygon; it is a knob, so this is only the start. */
|
|
157
|
+
const DOTS = 6;
|
|
158
|
+
|
|
159
|
+
/* Every window is real: tails of the recorded session (09:00 to 17:30 across 34 readings), so the balance is the
|
|
160
|
+
same number in all three and only the change differs. */
|
|
161
|
+
const WINDOWS = [
|
|
162
|
+
{ id: "1h", label: "1H", take: 5, span: "última hora" },
|
|
163
|
+
{ id: "4h", label: "4H", take: 17, span: "últimas 4 horas" },
|
|
164
|
+
{ id: "1d", label: "1D", take: 34, span: "hoje" },
|
|
165
|
+
] as const;
|
|
166
|
+
|
|
167
|
+
/* A curve, not a polyline: Catmull-Rom converted to cubic béziers, so it passes through every reading exactly and
|
|
168
|
+
the control points come from the neighbours on either side. The stroke, the points and the evaluator come out of
|
|
169
|
+
one pass because they all have to agree: a scrub dot must sit on the curve, not beside it. */
|
|
170
|
+
function curve(vals: number[], w: number, h: number, pad: number) {
|
|
171
|
+
const lo = Math.min(...vals);
|
|
172
|
+
const hi = Math.max(...vals);
|
|
173
|
+
const pt = vals.map((v, i) => ({
|
|
174
|
+
x: (i / (vals.length - 1)) * w,
|
|
175
|
+
y: h - pad - ((v - lo) / (hi - lo || 1)) * (h - pad * 2),
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
const seg = pt.slice(0, -1).map((p1, i) => {
|
|
179
|
+
const p0 = pt[i - 1] ?? p1;
|
|
180
|
+
const p2 = pt[i + 1];
|
|
181
|
+
const p3 = pt[i + 2] ?? p2;
|
|
182
|
+
/* a sixth of the neighbour span is the standard Catmull-Rom tension: tighter and it corners, looser and it loops */
|
|
183
|
+
return {
|
|
184
|
+
p1,
|
|
185
|
+
p2,
|
|
186
|
+
c1: { x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 },
|
|
187
|
+
c2: { x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 },
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
let d = `M${pt[0].x.toFixed(2)} ${pt[0].y.toFixed(2)}`;
|
|
192
|
+
for (const g of seg) {
|
|
193
|
+
d += ` C${g.c1.x.toFixed(2)} ${g.c1.y.toFixed(2)} ${g.c2.x.toFixed(2)} ${g.c2.y.toFixed(2)} ${g.p2.x.toFixed(2)} ${g.p2.y.toFixed(2)}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* Anywhere along the line, not just at a reading: the same cubic the path is made of, at a fractional index.
|
|
197
|
+
Lerping between two readings would put the dot on the chord, which is beside the curve wherever it bends. */
|
|
198
|
+
const at = (u: number) => {
|
|
199
|
+
const i = Math.min(seg.length - 1, Math.max(0, Math.floor(u)));
|
|
200
|
+
const g = seg[i];
|
|
201
|
+
const t = Math.min(1, Math.max(0, u - i));
|
|
202
|
+
const m = 1 - t;
|
|
203
|
+
return {
|
|
204
|
+
x: m ** 3 * g.p1.x + 3 * m * m * t * g.c1.x + 3 * m * t * t * g.c2.x + t ** 3 * g.p2.x,
|
|
205
|
+
y: m ** 3 * g.p1.y + 3 * m * m * t * g.c1.y + 3 * m * t * t * g.c2.y + t ** 3 * g.p2.y,
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
return { d, pt, at };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* the card's corner (a knob); the tabs follow it */
|
|
213
|
+
const CORNER = 20;
|
|
214
|
+
|
|
215
|
+
/* The room the rebound compresses into: negative padding does not exist, so the card carries this much at rest and
|
|
216
|
+
the bounce spends it. */
|
|
217
|
+
const SQUASH = 8;
|
|
218
|
+
|
|
219
|
+
/* How far the card can stretch. The stylesheet cancels the growth in layout with a matching negative margin, so the
|
|
220
|
+
card paints taller and measures the same; what is left is a limit of taste. `tanh` is near-linear where it
|
|
221
|
+
matters (at 40 of pull the card has grown 38, so it follows the finger) and bends only near the top: 68 at the
|
|
222
|
+
58px threshold, 104 at a 200px heave against a ceiling of 110. */
|
|
223
|
+
const GROW = 110;
|
|
224
|
+
|
|
225
|
+
export function PullToRefresh({
|
|
226
|
+
/* how much the pull fights back, 0..100 */
|
|
227
|
+
resistance = 50,
|
|
228
|
+
/* px before it commits */
|
|
229
|
+
threshold = 58,
|
|
230
|
+
/* how fast the ring turns while it works, 0..100 */
|
|
231
|
+
spin = 50,
|
|
232
|
+
/* how many dots the loading wheel is made of, 3..10 */
|
|
233
|
+
dots = DOTS,
|
|
234
|
+
/* the card's own corner, 0..40; the tabs follow it */
|
|
235
|
+
corner = CORNER,
|
|
236
|
+
}: {
|
|
237
|
+
resistance?: number;
|
|
238
|
+
threshold?: number;
|
|
239
|
+
spin?: number;
|
|
240
|
+
dots?: number;
|
|
241
|
+
corner?: number;
|
|
242
|
+
} = {}) {
|
|
243
|
+
const scroll = useRef<HTMLDivElement | null>(null);
|
|
244
|
+
const grab = useRef<{ x: number; y: number; from: number; on: boolean } | null>(null);
|
|
245
|
+
const off = useRef(0);
|
|
246
|
+
const [raw, setRaw] = useState(0);
|
|
247
|
+
const [phase, setPhase] = useState<"idle" | "hold" | "work">("idle");
|
|
248
|
+
/* which reading is on screen; a refresh advances it, so the gesture has an outcome you can point at */
|
|
249
|
+
const [read, setRead] = useState(0);
|
|
250
|
+
/* which point the pointer is over (a fractional index), or null for "the latest" */
|
|
251
|
+
const [scrub, setScrub] = useState<number | null>(null);
|
|
252
|
+
/* how much of the session is drawn; the whole day by default */
|
|
253
|
+
const [win, setWin] = useState(WINDOWS.length - 1);
|
|
254
|
+
const still = stillness();
|
|
255
|
+
const { box, k } = useGooScale();
|
|
256
|
+
|
|
257
|
+
/* the tail of the reading, as long as the chosen window */
|
|
258
|
+
const day = READS[read % READS.length];
|
|
259
|
+
const series = day.slice(Math.max(0, day.length - WINDOWS[win].take));
|
|
260
|
+
/* where the window starts in the day: the first point of the 1H window is half past four, not nine, and the same
|
|
261
|
+
reading must report the same time in every window */
|
|
262
|
+
const offset = day.length - series.length;
|
|
263
|
+
useEffect(() => () => window.clearTimeout(off.current), []);
|
|
264
|
+
|
|
265
|
+
/* Rubber, not a rail: a linear pull tells you nothing on the way. This saturates, so the last twenty pixels cost
|
|
266
|
+
more than the first twenty. R is where the curve gives up and has to stay well above the threshold's ceiling,
|
|
267
|
+
or the top of the range would be a pull that can never commit. */
|
|
268
|
+
const R = 700 - (resistance / 100) * 380;
|
|
269
|
+
const drawn = (R * raw) / (R + raw);
|
|
270
|
+
const target = phase === "work" ? threshold : phase === "hold" ? drawn : 0;
|
|
271
|
+
/* Tracking the finger is instant, because it is the finger. The spring is only ever the journey home. */
|
|
272
|
+
const sprung = useSpring(target, 58, still || phase === "hold");
|
|
273
|
+
/* clamped at zero: the content is at the top of its own scroller and there is nothing above it to reveal */
|
|
274
|
+
const at = Math.max(0, sprung);
|
|
275
|
+
|
|
276
|
+
/* The sheet slides down and the card follows it, growing by the same number, so the top edge stays put and the
|
|
277
|
+
bottom travels with the finger. It rides `sprung`, not the clamped `at`: that unclamped tail is the bounce.
|
|
278
|
+
The spring at tune 58 is under-damped, so on the way home it goes past zero before it settles, and the card
|
|
279
|
+
compresses a few pixels below its resting height (SQUASH is the padding it carries at rest to give back). */
|
|
280
|
+
const grow =
|
|
281
|
+
sprung >= 0
|
|
282
|
+
? GROW * Math.tanh(sprung / GROW)
|
|
283
|
+
: /* the rebound is not damped: it is a short sharp give, and the same curve would flatten the one part that
|
|
284
|
+
is supposed to snap */
|
|
285
|
+
Math.max(-SQUASH, sprung);
|
|
286
|
+
|
|
287
|
+
/* how far through the gesture we are, and whether it has committed; read off the same number, so the ring and
|
|
288
|
+
the decision cannot disagree */
|
|
289
|
+
const p = clamp(at / threshold, 0, 1);
|
|
290
|
+
const armed = p >= 1;
|
|
291
|
+
|
|
292
|
+
/* One index feeds the balance, the change, the time and the dot on the line, so they cannot drift apart. It is
|
|
293
|
+
continuous: snapping to a reading moved the number in steps and the dot in hops, and the hops were the tell,
|
|
294
|
+
because the line under it is a smooth curve. */
|
|
295
|
+
const last = series.length - 1;
|
|
296
|
+
const shown = scrub ?? last;
|
|
297
|
+
const lo = Math.floor(shown);
|
|
298
|
+
const hi = Math.min(last, Math.ceil(shown));
|
|
299
|
+
const value = mix(series[lo], series[hi], shown - lo);
|
|
300
|
+
const change = value - series[0];
|
|
301
|
+
const up = change >= 0;
|
|
302
|
+
const pct = (change / series[0]) * 100;
|
|
303
|
+
const [whole, cents] = split(value);
|
|
304
|
+
|
|
305
|
+
/* The gesture is undecided until it has moved a few pixels, then becomes exactly one of three things: a pull, a
|
|
306
|
+
scroll (upward) or a scrub (sideways). Pointer capture keeps it from stranding mid-gesture, and one pixel of
|
|
307
|
+
upward jitter no longer throws it away. */
|
|
308
|
+
const DEADZONE = 4;
|
|
309
|
+
|
|
310
|
+
const start = (e: ReactPointerEvent) => {
|
|
311
|
+
const sc = scroll.current;
|
|
312
|
+
if (!sc || phase === "work" || sc.scrollTop > 0) return;
|
|
313
|
+
grab.current = { x: e.clientX, y: e.clientY, from: zoomOf(sc), on: false };
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const move = (e: ReactPointerEvent) => {
|
|
317
|
+
const g = grab.current;
|
|
318
|
+
if (!g) return;
|
|
319
|
+
const dy = (e.clientY - g.y) / g.from;
|
|
320
|
+
const dx = (e.clientX - g.x) / g.from;
|
|
321
|
+
|
|
322
|
+
if (!g.on) {
|
|
323
|
+
/* upward: this is a scroll, not a pull */
|
|
324
|
+
if (dy < -DEADZONE) {
|
|
325
|
+
grab.current = null;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
/* sideways is a scrub: any sideways travel carries a pixel or two of downward drift, which used to commit a
|
|
329
|
+
pull and swallow the gesture. Whichever axis is winning owns it. */
|
|
330
|
+
if (Math.abs(dx) > DEADZONE && Math.abs(dx) > Math.abs(dy)) {
|
|
331
|
+
grab.current = null;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (dy < DEADZONE) return;
|
|
335
|
+
g.on = true;
|
|
336
|
+
/* the pointer is ours until it is released, wherever it goes */
|
|
337
|
+
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
|
338
|
+
/* a pull is not a reading: put the card back on "now" */
|
|
339
|
+
setScrub(null);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setPhase("hold");
|
|
343
|
+
/* clamped rather than cancelled: pulling back past where you started is still the same pull */
|
|
344
|
+
setRaw(Math.max(0, dy));
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const done = () => {
|
|
348
|
+
const g = grab.current;
|
|
349
|
+
if (!g) return;
|
|
350
|
+
grab.current = null;
|
|
351
|
+
setRaw(0);
|
|
352
|
+
/* an uncommitted press never pulled anything, so it has nothing to decide */
|
|
353
|
+
if (!g.on) return;
|
|
354
|
+
if (armed) refresh();
|
|
355
|
+
else setPhase("idle");
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/* the outcome, immediately; the ring turning afterwards is a report on something that has already happened */
|
|
359
|
+
const refresh = () => {
|
|
360
|
+
setPhase("work");
|
|
361
|
+
setScrub(null);
|
|
362
|
+
window.clearTimeout(off.current);
|
|
363
|
+
off.current = window.setTimeout(() => {
|
|
364
|
+
/* the new figure lands with the spring back, not before it */
|
|
365
|
+
setRead(n => n + 1);
|
|
366
|
+
setPhase("idle");
|
|
367
|
+
}, 1150);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/* The scrub is read as a fraction of the plot's own box, so it needs no zoom correction: both sides of the
|
|
371
|
+
division are screen pixels. */
|
|
372
|
+
const onScrub = (e: ReactPointerEvent) => {
|
|
373
|
+
if (grab.current?.on || phase === "work") return;
|
|
374
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
375
|
+
const t = clamp((e.clientX - r.left) / (r.width || 1), 0, 1);
|
|
376
|
+
setScrub(t * last);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/* Clamped rather than trusted. */
|
|
380
|
+
const n = Math.max(3, Math.min(10, Math.round(dots)));
|
|
381
|
+
|
|
382
|
+
/* The ring grows with the count: at ten dots a fixed radius would be crowded, so the radius takes a pixel and a
|
|
383
|
+
bit per dot above six and gives one back below it, keeping the gap between neighbours roughly constant. */
|
|
384
|
+
const ring = 12 + (n - DOTS) * 1.3;
|
|
385
|
+
/* the dots draw together as you pull and open back into a ring while it works */
|
|
386
|
+
const spread = phase === "work" ? ring : ring + 3 - p * 3;
|
|
387
|
+
/* the ring winds with the finger; while it works the whole group turns instead, on its own clock */
|
|
388
|
+
const turn = phase === "work" ? 0 : p * 220;
|
|
389
|
+
const c = curve(series, 100, 40, 4);
|
|
390
|
+
const dot = c.at(shown);
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<div
|
|
394
|
+
className="bn-bal"
|
|
395
|
+
ref={box}
|
|
396
|
+
data-phase={phase}
|
|
397
|
+
data-armed={armed}
|
|
398
|
+
data-flat={still || undefined}
|
|
399
|
+
style={
|
|
400
|
+
{
|
|
401
|
+
"--at": `${at.toFixed(2)}px`,
|
|
402
|
+
/* what the card's own height gains, rest padding included */
|
|
403
|
+
"--grow": `${(SQUASH + grow).toFixed(2)}px`,
|
|
404
|
+
/* how formed the ring is: 0 while the pull is barely started, 1 the moment it commits */
|
|
405
|
+
"--p": p.toFixed(3),
|
|
406
|
+
/* slow: 2.4s a turn at the default and never under 1.4, since a spinner that races reads as panic */
|
|
407
|
+
"--rpm": `${(3000 - (spin / 100) * 1200).toFixed(0)}ms`,
|
|
408
|
+
"--k": k,
|
|
409
|
+
/* one knob, two corners: the tab is 32 tall, so a radius past 16 is the same pill, and the default lands
|
|
410
|
+
on it */
|
|
411
|
+
"--bal-r": `${clamp(corner, 0, 40)}px`,
|
|
412
|
+
"--bal-tab-r": `${Math.min(16, clamp(corner, 0, 40) * 0.62).toFixed(2)}px`,
|
|
413
|
+
} as CSSProperties
|
|
414
|
+
}
|
|
415
|
+
>
|
|
416
|
+
{/* the indicator sits under the sheet and is revealed as the sheet is pulled down off it. Dots placed with
|
|
417
|
+
sine and cosine rather than by rotating each one, so a dot stays a dot at every angle; the ring turns as
|
|
418
|
+
one element, linearly. */}
|
|
419
|
+
<div className="bn-bal-goo" aria-hidden={phase === "idle"}>
|
|
420
|
+
{Array.from({ length: n }, (_, i) => {
|
|
421
|
+
const a = (((i / n) * 360 + turn) * Math.PI) / 180;
|
|
422
|
+
return (
|
|
423
|
+
<span
|
|
424
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed ring of identical dots
|
|
425
|
+
key={i}
|
|
426
|
+
className="bn-bal-drop"
|
|
427
|
+
style={
|
|
428
|
+
{
|
|
429
|
+
"--dx": (Math.sin(a) * spread).toFixed(2),
|
|
430
|
+
"--dy": (-Math.cos(a) * spread).toFixed(2),
|
|
431
|
+
} as CSSProperties
|
|
432
|
+
}
|
|
433
|
+
/>
|
|
434
|
+
);
|
|
435
|
+
})}
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
<div
|
|
439
|
+
className="bn-bal-scroll"
|
|
440
|
+
ref={scroll}
|
|
441
|
+
/* the keyboard route in: a drag is not something a keyboard can express */
|
|
442
|
+
tabIndex={0}
|
|
443
|
+
role="group"
|
|
444
|
+
aria-label="Carteira. Pressione Enter para atualizar."
|
|
445
|
+
onKeyDown={e => {
|
|
446
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
447
|
+
e.preventDefault();
|
|
448
|
+
if (phase !== "work") refresh();
|
|
449
|
+
}}
|
|
450
|
+
onPointerDown={start}
|
|
451
|
+
onPointerMove={move}
|
|
452
|
+
onPointerUp={done}
|
|
453
|
+
onPointerCancel={done}
|
|
454
|
+
/* the last resort: capture can be taken away (another pointer, a scroll the browser decides to own) */
|
|
455
|
+
onLostPointerCapture={done}
|
|
456
|
+
>
|
|
457
|
+
<div className="bn-bal-sheet">
|
|
458
|
+
<p className="bn-bal-sum">
|
|
459
|
+
<span className="bn-bal-cur">R$</span>
|
|
460
|
+
{whole}
|
|
461
|
+
<span className="bn-bal-dec">{cents}</span>
|
|
462
|
+
</p>
|
|
463
|
+
{/* the change and, while you are reading the line back, when; same shape either way, so the row does not
|
|
464
|
+
reflow as you scrub across it */}
|
|
465
|
+
<p className="bn-bal-move" data-up={up}>
|
|
466
|
+
{signed(change)} · {percent(Math.abs(pct))}%
|
|
467
|
+
<span className="bn-bal-when">
|
|
468
|
+
{scrub === null ? WINDOWS[win].span : clock(offset + shown, day.length)}
|
|
469
|
+
</span>
|
|
470
|
+
</p>
|
|
471
|
+
|
|
472
|
+
{/* The svg stretches with preserveAspectRatio "none" so one 100-unit series fits any card width, and that
|
|
473
|
+
stretches everything: a circle inside it comes out an ellipse. The curve survives because a stroke can
|
|
474
|
+
opt out with vector-effect; a filled shape cannot. So the dot and the guide are elements over the top,
|
|
475
|
+
positioned from the same points the path was built from. */}
|
|
476
|
+
<span
|
|
477
|
+
className="bn-bal-plot"
|
|
478
|
+
data-up={up}
|
|
479
|
+
data-scrub={scrub !== null || undefined}
|
|
480
|
+
onPointerMove={onScrub}
|
|
481
|
+
onPointerDown={onScrub}
|
|
482
|
+
onPointerLeave={() => setScrub(null)}
|
|
483
|
+
>
|
|
484
|
+
<svg className="bn-bal-line" viewBox="0 0 100 40" preserveAspectRatio="none" aria-hidden="true">
|
|
485
|
+
<path className="bn-bal-curve" d={c.d} />
|
|
486
|
+
</svg>
|
|
487
|
+
{/* only while reading: a permanent guide is a second axis nobody asked for */}
|
|
488
|
+
<i className="bn-bal-guide" style={{ left: `${dot.x}%` }} />
|
|
489
|
+
{/* down the chart as a fraction, which the stylesheet resolves against the chart's height rather than
|
|
490
|
+
the plot's, which is 12px taller */}
|
|
491
|
+
<i className="bn-bal-tip" style={{ left: `${dot.x}%`, ["--tip" as string]: (dot.y / 40).toFixed(4) }} />
|
|
492
|
+
</span>
|
|
493
|
+
|
|
494
|
+
<div
|
|
495
|
+
className="bn-bal-win"
|
|
496
|
+
role="group"
|
|
497
|
+
aria-label="Janela de tempo"
|
|
498
|
+
style={{ "--win": win, "--win-n": WINDOWS.length } as CSSProperties}
|
|
499
|
+
>
|
|
500
|
+
{/* One pill that travels, rather than three backgrounds taking turns: a shape that moves says "this
|
|
501
|
+
one, and it came from that one". Placed by arithmetic in CSS, not by measuring: every tab is
|
|
502
|
+
`flex: 1`, and a translate in percent of the pill's own width is exactly one tab per step. */}
|
|
503
|
+
<span className="bn-bal-win-pill" aria-hidden="true" />
|
|
504
|
+
{WINDOWS.map((wdw, i) => (
|
|
505
|
+
<button
|
|
506
|
+
type="button"
|
|
507
|
+
key={wdw.id}
|
|
508
|
+
className="bn-bal-win-btn"
|
|
509
|
+
data-on={i === win || undefined}
|
|
510
|
+
aria-pressed={i === win}
|
|
511
|
+
onClick={() => {
|
|
512
|
+
if (i === win) return;
|
|
513
|
+
/* a window change is a different question, so the reading you were pointing at is no longer the
|
|
514
|
+
one you meant */
|
|
515
|
+
setScrub(null);
|
|
516
|
+
setWin(i);
|
|
517
|
+
}}
|
|
518
|
+
>
|
|
519
|
+
{wdw.label}
|
|
520
|
+
</button>
|
|
521
|
+
))}
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
}
|