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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Vertical marker stacks stay on the plot.** A `"vertical"` marker column
whose anchor sits near the top or bottom of the canvas no longer climbs off
the chart: the column is now also capped where the next glyph would cross the
canvas edge, hiding the overflow the same way `maxVisible` does. The base
glyph always draws.

- **Tail-less badge sits flush at the gutter edge.** With `badge={{ tail: false }}`
the layout no longer reserves the round-cap inset, which left a dead gap
between the plot edge and the pill. The pill body now starts right after the
Expand Down
5 changes: 5 additions & 0 deletions docs/guides/markers-and-trades.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ tall column doesn't collapse to a count badge early.
markerCluster={{ direction: "vertical", overlap: 0.6, maxBeforeGroup: 20 }}
```

A column whose anchor sits near the top or bottom of the canvas is clamped at
the canvas edge automatically: glyphs that would land off the plot are hidden
(the base glyph always draws), the same overflow treatment as `maxVisible`
below.

#### Cap a vertical column

`maxVisible` limits a **vertical** column to its oldest glyphs, keeping the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,11 @@ export function CustomMarkerOverlay({
lineData: lineData?.get(),
lineLinear,
});
clusterMarkers(ms, buf, { config: cluster });
clusterMarkers(ms, buf, {
config: cluster,
minY: 0,
maxY: engine.canvasHeight.get(),
});
const map: Record<string, ProjectedMarker> = {};
for (let i = 0; i < ms.length; i++) {
if (customIds[ms[i].id]) map[ms[i].id] = buf[i];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,11 @@ export function MarkerOverlay({
lineData: lineData?.get(),
lineLinear,
});
clusterMarkers(ms, buf, { config: cluster });
clusterMarkers(ms, buf, {
config: cluster,
minY: 0,
maxY: engine.canvasHeight.get(),
});
const atlasFrames = atlasFrameRef.current!;
atlasFrames.tick = !atlasFrames.tick;
const frame = atlasFrames.tick ? atlasFrames.a : atlasFrames.b;
Expand Down
6 changes: 5 additions & 1 deletion packages/react-native-livechart/src/hooks/useMarkers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ export function useMarkers(
lineData: lineData?.get(),
lineLinear,
});
clusterMarkers(markers.get(), buf, { config: cluster });
clusterMarkers(markers.get(), buf, {
config: cluster,
minY: 0,
maxY: engine.canvasHeight.get(),
});
projected.set(buf);
},
autostart,
Expand Down
16 changes: 15 additions & 1 deletion packages/react-native-livechart/src/math/markerCluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface ResolvedMarkerCluster {

export interface ClusterMarkersOpts {
config: ResolvedMarkerCluster;
/** Canvas-space y bounds (typically `0` / canvas height). When set, a
* `"vertical"` column is additionally capped where the next glyph would
* cross a bound, so a tall stack can't climb off the chart when its anchor
* is already near the edge. The base slot always draws. */
minY?: number;
maxY?: number;
}

/** Glyph box used when `marker.size` is unset — mirrors `markerAtlas.DEFAULT_ICON_SIZE`. */
Expand Down Expand Up @@ -138,7 +144,15 @@ function layoutBucket(
// `maxVisible` caps the column: the oldest glyphs keep their slots and the
// newest overflow is simply hidden.
const dir = side === "below" ? 1 : -1;
const cap = opts.config.maxVisible;
let cap = opts.config.maxVisible;
// Bounds clamp: slot j sits at `base + dir * j * step`; keep only slots
// whose glyph box stays inside [minY, maxY].
if (opts.minY !== undefined && opts.maxY !== undefined && step > 0) {
const base = anchorY + sideDy;
const room = dir === -1 ? base - h / 2 - opts.minY : opts.maxY - h / 2 - base;
const fit = 1 + Math.floor(room / step);
if (fit < cap) cap = Math.max(1, fit);
}
for (let j = 0; j < count; j++) {
const p = proj[idx[s + j]];
if (j >= cap) {
Expand Down
83 changes: 83 additions & 0 deletions packages/react-native-livechart/tests/math/markerCluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,86 @@ describe("clusterMarkers — stacked vertical", () => {
expect(proj.slice(0, 5).every((p) => p.hidden && p.groupRep === 5)).toBe(true);
});
});

describe("clusterMarkers — stacked vertical with canvas bounds", () => {
const STEP = 16 * (1 - 0.6); // glyphHeight(trade) * (1 - overlap) = 6.4

it("clamps an `above` column where the next glyph would cross minY", () => {
// Base at 25 - (16/2 + 2) = 15; room above = 15 - 8 = 7 → 1 + floor(7/6.4) = 2 slots.
const markers = Array.from({ length: 4 }, (_, i) => trade(`m${i}`, i + 1, "above"));
const proj = markers.map(() => pm(100, 25));
clusterMarkers(markers, proj, { config: STACKED_VERTICAL, minY: 0, maxY: 200 });
expect(proj[0].hidden).toBe(false);
expect(proj[0].y).toBeCloseTo(15);
expect(proj[1].hidden).toBe(false);
expect(proj[1].y).toBeCloseTo(15 - STEP);
expect(proj[2].hidden).toBe(true); // would sit at 2.2 with its top at -5.8
expect(proj[3].hidden).toBe(true);
});

it("clamps a `below` column where the next glyph would cross maxY", () => {
// Base at 175 + (16/2 + 2) = 185; room below = 200 - 8 - 185 = 7 → 2 slots.
const markers = Array.from({ length: 4 }, (_, i) => trade(`m${i}`, i + 1, "below"));
const proj = markers.map(() => pm(100, 175));
clusterMarkers(markers, proj, { config: STACKED_VERTICAL, minY: 0, maxY: 200 });
expect(proj[0].hidden).toBe(false);
expect(proj[0].y).toBeCloseTo(185);
expect(proj[1].hidden).toBe(false);
expect(proj[1].y).toBeCloseTo(185 + STEP);
expect(proj[2].hidden).toBe(true);
expect(proj[3].hidden).toBe(true);
});

it("always draws the base slot even when it already overflows the bounds", () => {
// Base at 5 - 10 = -5 is above minY entirely (negative room) — cap floors at 1.
const markers = Array.from({ length: 3 }, (_, i) => trade(`m${i}`, i + 1, "above"));
const proj = markers.map(() => pm(100, 5));
clusterMarkers(markers, proj, { config: STACKED_VERTICAL, minY: 0, maxY: 200 });
expect(proj[0].hidden).toBe(false);
expect(proj[0].y).toBeCloseTo(-5);
expect(proj[1].hidden).toBe(true);
expect(proj[2].hidden).toBe(true);
});

it("applies the smaller of maxVisible and the bounds cap", () => {
const markers = Array.from({ length: 4 }, (_, i) => trade(`m${i}`, i + 1, "above"));
// Plenty of room (base 140, minY 0 fits 20+): maxVisible = 2 wins.
const roomy = markers.map(() => pm(100, 150));
clusterMarkers(markers, roomy, {
config: { ...STACKED_VERTICAL, maxVisible: 2 },
minY: 0,
maxY: 200,
});
expect(roomy.map((p) => p.hidden)).toEqual([false, false, true, true]);
// Tight room (base 15 fits 2): the bounds cap wins over maxVisible = 3.
const tight = markers.map(() => pm(100, 25));
clusterMarkers(markers, tight, {
config: { ...STACKED_VERTICAL, maxVisible: 3 },
minY: 0,
maxY: 200,
});
expect(tight.map((p) => p.hidden)).toEqual([false, false, true, true]);
});

it("ignores bounds unless both minY and maxY are provided", () => {
const markers = Array.from({ length: 4 }, (_, i) => trade(`m${i}`, i + 1, "above"));
const proj = markers.map(() => pm(100, 25));
clusterMarkers(markers, proj, { config: STACKED_VERTICAL, minY: 0 });
// Pass-through: the full column lays out, even past the would-be bound.
expect(proj.every((p) => !p.hidden)).toBe(true);
expect(proj[3].y).toBeCloseTo(15 - 3 * STEP); // top glyph center at -4.2
});

it("skips the bounds clamp when the fan step is 0 (overlap 1)", () => {
// step = h * (1 - 1) = 0: all glyphs share the base slot; no division by 0.
const markers = Array.from({ length: 3 }, (_, i) => trade(`m${i}`, i + 1, "above"));
const proj = markers.map(() => pm(100, 25));
clusterMarkers(markers, proj, {
config: { ...STACKED_VERTICAL, overlap: 1 },
minY: 0,
maxY: 200,
});
expect(proj.every((p) => !p.hidden)).toBe(true);
expect(proj.every((p) => p.y === 15)).toBe(true);
});
});
Loading