goon/mobile/src/lib/liquidTabPath.ts
goon-foss e074229bbb feat(ui): pasek zakladek roztapia sie wokol aktywnej pozycji
Wzorowane na reelu _code_and_chill_ ("the bar melts around it"), ale
technika wzieta z jego drugiego zdania: "every socket is solved for
tangency, so the liquid never creases". Czyli sciezka LICZONA, nie
rozmywana.

Filtr "goo" (rozmycie + prog alfy) byl pierwszym pomyslem i zostal
ODRZUCONY po zajrzeniu w zrodlo react-native-svg. Filtry na Androidzie
dzialaja (GroupView extends RenderableView, ktory obsluguje mFilter), ale
RenderableView.java:376 przy KAZDYM rysowaniu robi Bitmap.createBitmap(
canvas.getWidth(), canvas.getHeight(), ARGB_8888), rysuje do niej element,
naklada programowy filtr i wrzuca wynik z powrotem. Przy animowanej kropli
to pelnoekranowa alokacja na klatke, w nagłowku KAZDEGO ekranu.

liquidTabPath: gniazdo to dwa luki styczne jednoczesnie do krawedzi paska
(srodek na y=top+k) i do kropli (odleglosc srodkow = r+k), stad
dx = sqrt((r+k)^2 - (top+k-cy)^2). Jedna <Path>, zero alokacji.

Weryfikacja liczbowa (nie wizualna): trzy niezmienniki stycznosci trzymaja
sie do 1e-9, piec przypadkow brzegowych OK. Test wylapal brakujacy warunek
`cy - r < top`: bez niego wzor ma rozwiazanie takze gdy kropla siedzi CALA
w pasku (top=26, cy=45, r=17, k=12 -> dx≈28,1) i powstawaloby gniazdo, w
ktore nie ma sie co wtopic.

Animacja: Animated.Value + listener przepisujacy cx do stanu, bo sciezka
to string i nie da sie jej podac Animated wprost (brak Reanimated w
projekcie). ~12 re-renderow SVG na 200 ms przejscia, poza tym komponent stoi.
useNativeDriver=false swiadomie: animujemy geometrie, nie transform.

Etykieta aktywnej zakladki siedzi na wypelnieniu akcentu — jasny tekst daje
tam 5,03:1, jedyny wariant tego koloru przechodzacy AA (patrz theme.ts).
2026-08-14 10:13:30 +02:00

118 lines
4.8 KiB
TypeScript

/**
* Ścieżka paska zakładek, który „roztapia się" wokół aktywnej pozycji.
*
* Technika wzięta z reela `_code_and_chill_`: aktywna zakładka nie jest przesuwającą się
* pigułką, tylko kroplą wyrastającą z paska, a przejście między nimi to **łuki styczne**
* do jednego i drugiego. Autor pisze „every socket is solved for tangency, so the liquid
* never creases" i to jest dosłownie opis tej funkcji.
*
* **Dlaczego nie filtr „goo" (rozmycie + próg alfy).** Byłby o rząd wielkości prostszy,
* ale na Androidzie `react-native-svg` stosuje filtry przez pełną bitmapę: w
* `RenderableView.java` każde rysowanie robi `Bitmap.createBitmap(canvas.getWidth(),
* canvas.getHeight(), ARGB_8888)`, rysuje do niej element, nakłada programowy filtr
* i wrzuca wynik z powrotem. Przy animowanej kropli to alokacja pełnoekranowej bitmapy
* NA KLATKĘ, w nagłówku każdego ekranu. Ścieżka liczona analitycznie to jeden `<Path>`,
* bez alokacji i bez filtra.
*
* Geometria (oś Y rośnie w dół, jak w SVG):
*
* ╭─ kropla (cx, cy, r)
* ───╯ ╰─── ← łuki styczne (fillet) o promieniu k, siedzące W pasku
* ───────── ← górna krawędź paska, y = top
*
* Łuk styczny ma środek na `y = top + k` (styczność do krawędzi paska) i jest stycznie
* zewnętrznie do kropli, czyli odległość jego środka od środka kropli wynosi `r + k`.
* Stąd pozioma odległość: `dx = sqrt((r+k)² - (top + k - cy)²)`.
*
* Gdy kropla siedzi zbyt głęboko w pasku, wyrażenie pod pierwiastkiem schodzi poniżej
* zera i styczność nie istnieje — wtedy oddajemy sam pasek, bo lepszy prosty prostokąt
* niż `NaN` w ścieżce (RN rysuje wtedy nic i pasek znika).
*/
export interface LiquidTabPathOpts {
/** Szerokość paska. */
width: number;
/** Górna krawędź paska (Y). */
top: number;
/** Dolna krawędź paska (Y). */
bottom: number;
/** Środek kropli w poziomie. */
cx: number;
/** Środek kropli w pionie (zwykle powyżej `top`). */
cy: number;
/** Promień kropli. */
r: number;
/** Promień łuku stycznego. Im większy, tym „bardziej ciekły" przelew. */
fillet: number;
/** Zaokrąglenie końców paska. */
barRadius: number;
}
/** Bezpieczny pierwiastek: ujemne pod pierwiastkiem → brak styczności. */
function safeSqrt(v: number): number | null {
return v > 0 ? Math.sqrt(v) : null;
}
/**
* Zwraca `d` dla pojedynczej `<Path>`: pasek z wtopioną w niego kroplą.
* Kształt jest domknięty, więc nadaje się do wypełnienia jednym kolorem.
*/
export function liquidTabPath(o: LiquidTabPathOpts): string {
const { width, top, bottom, cx, cy, r, fillet: k, barRadius: br } = o;
// Kropla musi WYSTAWAĆ ponad krawędź paska. Bez tego warunku wzór na styczność ma
// rozwiązanie także wtedy, gdy kropla siedzi w całości w pasku: dla top=26, cy=45,
// r=17, k=12 wychodzi dx≈28,1, choć kropla sięga tylko do y=28, czyli jest pod
// krawędzią. Powstałoby gniazdo, w które nie ma się co wtopić. Widać to dopiero
// przy sprawdzeniu przypadków brzegowych, nie po samym wzorze.
const protrudes = cy - r < top;
const dxSq = (r + k) * (r + k) - (top + k - cy) * (top + k - cy);
const dx = protrudes ? safeSqrt(dxSq) : null;
// Brak styczności albo kropla nie wystaje → sam pasek, bez gniazda.
if (dx === null) {
return plainBar(width, top, bottom, br);
}
// Środki łuków stycznych, po obu stronach kropli.
const fLeftX = cx - dx;
const fRightX = cx + dx;
const fy = top + k;
// Punkty styczności łuk↔kropla: na prostej środek-kropli → środek-łuku, w odległości r.
const tp = (fx: number) => {
const vx = fx - cx;
const vy = fy - cy;
const len = Math.hypot(vx, vy) || 1;
return { x: cx + (vx / len) * r, y: cy + (vy / len) * r };
};
const tLeft = tp(fLeftX);
const tRight = tp(fRightX);
// Rysujemy zgodnie z ruchem wskazówek: górna krawędź od lewej, gniazdo, prawa krawędź,
// dół paska z powrotem. `sweepFlag` łuków gniazda = 0 (wklęsłe), kropli = 1 (wypukłe).
return [
`M ${br} ${top}`,
`L ${fLeftX} ${top}`,
`A ${k} ${k} 0 0 0 ${tLeft.x} ${tLeft.y}`,
`A ${r} ${r} 0 0 1 ${tRight.x} ${tRight.y}`,
`A ${k} ${k} 0 0 0 ${fRightX} ${top}`,
`L ${width - br} ${top}`,
`A ${br} ${br} 0 0 1 ${width - br} ${bottom}`,
`L ${br} ${bottom}`,
`A ${br} ${br} 0 0 1 ${br} ${top}`,
'Z',
].join(' ');
}
/** Sam pasek, bez gniazda. Używane też jako stan spoczynku. */
export function plainBar(width: number, top: number, bottom: number, br: number): string {
return [
`M ${br} ${top}`,
`L ${width - br} ${top}`,
`A ${br} ${br} 0 0 1 ${width - br} ${bottom}`,
`L ${br} ${bottom}`,
`A ${br} ${br} 0 0 1 ${br} ${top}`,
'Z',
].join(' ');
}