Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Changed

- **Candle mode skips building an unused line-overlay array** — in steady
candle mode the engine was assembling the line-mode point array (one
object per visible candle, or per visible tick during a density blend)
on every frame, which the draw layer then discarded because the line
overlay isn't drawn unless a line-mode or reveal transition is active.
It's now built only when it will actually be used. Internal only, no
visual change. Measured no CPU delta on a Debug simulator build — this
removes provably dead work rather than making live work faster.
- **Line-overlay presence logic consolidated** — the draw layer's "is the
candle-mode line overlay visible this frame" calculation and the engine's
companion "should I build its points" check previously lived as separate
inline expressions sharing a hardcoded threshold. Both now live in
`draw/lineOverlay.ts`, with a test asserting the invariant that keeps them
safe (the builder may only ever skip frames the drawer would ignore).
Behavior is unchanged — the extracted presence function is bit-identical
to the previous inline expression across the full input domain.

## [0.2.0] - 2026-07-27

### Added
Expand Down
4 changes: 4 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ and published-package Metro transform: consumer-side, deferred as planned.
`scrubActivationDelay` (example "Scroll" section) needs a real finger —
synthetic input can't reach RNGH Pan gestures on the sim.
- CI runs on GitHub once repo is pushed (workflows already scaffolded).
- Maintainability: see `PLAN_MAINT.md` (written 2026-07-28, not started) — a
seven-item structural pass over `src/`, the largest being the ~1700-line
`engineStep` and its three duplicated inline pipelines. Separate workstream
from this doc and from `PLAN_PERF.md`; explicitly *not* a perf pass.

## Task list mapping (session task tool)
#1–#5 phases 0–4 complete; #6 = remaining candle/orderbook runtime polish
Expand Down
348 changes: 348 additions & 0 deletions PLAN_MAINT.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions PLAN_PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,12 @@ a manual on-device check via the example app (iOS sim and/or Android
emulator — see `PLAN.md` for the device-testing workflow already established
during the original port). Commit each item separately rather than bundling
all four into one commit, so a regression in one is easy to bisect.

## Follow-on workstream

The structural debt this batch worked *around* rather than fixed is catalogued
in `PLAN_MAINT.md` (2026-07-28). Relevant to anyone resuming perf work: item 4
there (positional-parameter soup) already blocked one perf change — pooled
decimation scratch needed a 17th positional parameter on `drawLine` and was
reverted rather than pay that cost, to be redone once the signature takes an
options object.
112 changes: 112 additions & 0 deletions src/draw/__tests__/lineOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
lineOverlayPresence,
shouldBuildLineOverlay,
LINE_OVERLAY_MIN_PRESENCE,
} from '../lineOverlay';

// A dense grid over both inputs, including every threshold either function
// mentions and the values immediately on each side of them.
const GRID = [
0, 0.0001, 0.005, 0.00999, 0.01, 0.01001, 0.02, 0.05, 0.1, 0.2, 0.2154,
0.2155, 0.3, 0.5, 0.7, 0.78, 0.7846, 0.79, 0.9, 0.98, 0.98999, 0.99, 0.99001,
0.999, 1,
];

describe('lineOverlayPresence', () => {
it('is 0 in steady candle mode (no line mode, reveal complete)', () => {
expect(lineOverlayPresence(0, 1)).toBe(0);
});

it('is 1 in full line mode', () => {
expect(lineOverlayPresence(1, 1)).toBe(1);
});

it('tracks lineModeProg once the reveal has finished', () => {
for (const p of GRID) expect(lineOverlayPresence(p, 1)).toBeCloseTo(p);
});

it('cubes the reveal contribution in candle mode', () => {
// reveal=0.5 => inv=0.5 => 0.5^3 = 0.125, not 0.5.
expect(lineOverlayPresence(0, 0.5)).toBeCloseTo(0.125);
});

it('uses a linear reveal contribution in full line mode', () => {
// At lineModeProg >= 0.99 the reveal term is 1 - reveal, but the max with
// lineModeProg dominates unless reveal is very low.
expect(lineOverlayPresence(0.99, 0)).toBeCloseTo(1);
expect(lineOverlayPresence(0.99, 1)).toBeCloseTo(0.99);
});

it('stays within [0, 1] across the grid', () => {
for (const p of GRID) {
for (const r of GRID) {
const lp = lineOverlayPresence(p, r);
expect(lp).toBeGreaterThanOrEqual(0);
expect(lp).toBeLessThanOrEqual(1);
}
}
});

it('never decreases as lineModeProg rises at a fixed reveal', () => {
for (const r of GRID) {
let prev = -Infinity;
for (const p of GRID) {
const lp = lineOverlayPresence(p, r);
expect(lp).toBeGreaterThanOrEqual(prev);
prev = lp;
}
}
});
});

describe('shouldBuildLineOverlay', () => {
it('skips the build in steady candle mode', () => {
expect(shouldBuildLineOverlay(0, 1)).toBe(false);
});

it('builds while any line-mode transition is in progress', () => {
expect(shouldBuildLineOverlay(0.5, 1)).toBe(true);
expect(shouldBuildLineOverlay(1, 1)).toBe(true);
});

it('builds for the whole reveal, including its tail', () => {
expect(shouldBuildLineOverlay(0, 0)).toBe(true);
expect(shouldBuildLineOverlay(0, 0.5)).toBe(true);
expect(shouldBuildLineOverlay(0, 0.999)).toBe(true);
});

// THE load-bearing test. If either function is changed such that the
// engine could skip building an array the drawer still wants, the overlay
// silently disappears mid-transition — a bug with no test failure and no
// crash, visible only on a device, only during an animation. This asserts
// the one relationship that makes the optimization safe.
it('is true wherever the drawer would draw (never skips a wanted frame)', () => {
for (const p of GRID) {
for (const r of GRID) {
const drawerWouldDraw =
lineOverlayPresence(p, r) > LINE_OVERLAY_MIN_PRESENCE;
if (drawerWouldDraw) {
expect({
lineModeProg: p,
chartReveal: r,
builds: shouldBuildLineOverlay(p, r),
}).toEqual({ lineModeProg: p, chartReveal: r, builds: true });
}
}
}
});

// Guards the other direction: the gate has to actually skip something, or
// the optimization is a no-op and the complexity isn't paying for itself.
it('does skip real frames — steady candle mode is entirely excluded', () => {
const skipped = GRID.flatMap((p) =>
GRID.filter((r) => !shouldBuildLineOverlay(p, r)).map((r) => [p, r])
);
expect(skipped.length).toBeGreaterThan(0);
// Everything skipped must be reveal-complete and out of line mode.
for (const [p, r] of skipped) {
expect(r).toBe(1);
expect(p).toBeLessThanOrEqual(LINE_OVERLAY_MIN_PRESENCE);
}
});
});
18 changes: 8 additions & 10 deletions src/draw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Ctx2D } from './canvas2d';
import { drawGrid, type GridState } from './grid';
import type { GridLayerSlot } from './gridLayer';
import { drawLine } from './line';
import { lineOverlayPresence, LINE_OVERLAY_MIN_PRESENCE } from './lineOverlay';
import {
createLineCacheSlot,
type LineCacheRef,
Expand Down Expand Up @@ -727,13 +728,10 @@ export function drawCandleFrame(
const fullLineMode = opts.lineModeProg >= 0.99;

// Line presence (lp): during the reveal, the morph line smoothly
// transforms from the loading squiggly into data positions. In candle
// mode it fades much faster (cubed) so candles become dominant early
// and the morphing line never looks like a "line chart."
const revealLine = fullLineMode
? 1 - reveal
: (1 - reveal) * (1 - reveal) * (1 - reveal);
const lp = Math.max(opts.lineModeProg, revealLine);
// transforms from the loading squiggly into data positions. See
// draw/lineOverlay.ts — `engineStep` gates *building* the overlay's point
// array on the companion predicate there, so the two must stay in step.
const lp = lineOverlayPresence(opts.lineModeProg, reveal);

// colorBlend: when reveal drives lp, force grey (loading squiggly color).
// When the user's lineModeProg drives lp, use accent color.
Expand All @@ -755,7 +753,7 @@ export function drawCandleFrame(
// 2. Line — morph line that transforms from loading squiggly into data.
// Returns pts for dot position.
let linePts: [number, number][] | undefined;
if (lp > 0.01 && opts.lineVisible.length >= 2) {
if (lp > LINE_OVERLAY_MIN_PRESENCE && opts.lineVisible.length >= 2) {
const scrubX = opts.scrubAmount > 0.05 ? opts.hoverX : null;
ctx.save();
ctx.globalAlpha = lp;
Expand Down Expand Up @@ -800,7 +798,7 @@ export function drawCandleFrame(
}
// Accent-colored dash line (fades in with lineModeProg)
// Skip when fully in line mode — drawLine draws its own morphing dash
if (lp > 0.01 && !fullLineMode) {
if (lp > LINE_OVERLAY_MIN_PRESENCE && !fullLineMode) {
const dashY = layout.toY(closeSource.close);
if (dashY >= pad.top && dashY <= h - pad.bottom) {
ctx.save();
Expand Down Expand Up @@ -845,7 +843,7 @@ export function drawCandleFrame(

ctx.save();
ctx.clipRect(pad.left - 1, pad.top, chartW + 2, chartH);
const accentCol = lp > 0.01 ? palette.line : undefined;
const accentCol = lp > LINE_OVERLAY_MIN_PRESENCE ? palette.line : undefined;
if (opts.morphT >= 0 && revealOld.length > 0) {
ctx.globalAlpha = (1 - opts.morphT) * candleAlpha;
drawCandlesticks(
Expand Down
70 changes: 70 additions & 0 deletions src/draw/lineOverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* The candle-mode line overlay's presence gate, in one place.
*
* Candle mode can draw a line on top of the candles in two situations: the
* user is transitioning to/from line mode (`lineModeProg`), or the reveal
* morph is still running and the line is what the loading squiggly morphs
* out of (`chartReveal`). Two different modules need to agree about this:
*
* - `drawCandleFrame` (draw/index.ts) decides whether to *draw* it, from
* `lineOverlayPresence` — which is also the alpha the overlay is drawn at.
* - `engineStep` (engine/step.ts) decides whether to *build* the point array
* the drawer would consume, from `shouldBuildLineOverlay`.
*
* They were previously separate inline expressions sharing a hardcoded
* `0.01`, which is exactly the kind of coupling that rots: if the drawer's
* threshold moved and the builder's didn't, the builder would start
* skipping frames the drawer still wanted, and the overlay would vanish
* during a transition. Both live here now, and `lineOverlay.test.ts`
* asserts the invariant that keeps them safe — see `shouldBuildLineOverlay`.
*
* Deliberately free of Skia imports so jest can exercise it directly:
* `draw/index.ts` pulls in the native binding at import time, which the
* draw-layer unit tests can't load (same reasoning as `draw/pathCache.ts`,
* and the local `DASH_4_4` in `draw/line.ts`).
*/

/** Below this presence the overlay contributes nothing and isn't drawn. */
export const LINE_OVERLAY_MIN_PRESENCE = 0.01;

/**
* How present the line overlay is this frame, in [0, 1] — used directly as
* its draw alpha. The reveal contribution is cubed in candle mode so the
* candles become dominant early and the morphing line never reads as a
* "line chart"; in full line mode it's linear, because there the line *is*
* the chart.
*/
export function lineOverlayPresence(
lineModeProg: number,
chartReveal: number
): number {
'worklet';
const fullLineMode = lineModeProg >= 0.99;
const inv = 1 - chartReveal;
const revealLine = fullLineMode ? inv : inv * inv * inv;
return Math.max(lineModeProg, revealLine);
}

/**
* Whether `engineStep` should build the overlay's point array this frame.
*
* **Invariant:** this must be true whenever `lineOverlayPresence(...) >
* LINE_OVERLAY_MIN_PRESENCE` — i.e. it may only ever skip work the drawer
* was going to ignore anyway, never the reverse. It is deliberately
* *broader* than the drawer's test rather than an exact mirror of it:
* `chartReveal < 1` covers the entire reveal, including the tail where the
* cubed reveal term has already decayed under the threshold. Building a
* handful of unnecessary frames at the end of a reveal is free; skipping
* one the drawer wanted is a visible dropout.
*
* `lineOverlay.test.ts` asserts this over a dense grid of both inputs, so a
* future change to either function that breaks the relationship fails a
* test instead of shipping.
*/
export function shouldBuildLineOverlay(
lineModeProg: number,
chartReveal: number
): boolean {
'worklet';
return lineModeProg > LINE_OVERLAY_MIN_PRESENCE || chartReveal < 1;
}
Loading
Loading