From 697a0eaec60911fefbf73d004f47ad87c3d34d26 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Wed, 28 Oct 2026 10:38:00 +0100 Subject: [PATCH 1/2] fix: keep the graph lines off a live user's LIVE badge The LIVE badge hangs off the bottom of a live user's avatar, past the ring the graph canvas cuts out of its lines, so the line leaving the avatar ran over the badge. A node now also cuts out what it marks data-graph-overhang, measured with it and grown by the ring around it, and the badge on an avatar is marked so. Not unit-tested: the unit tests have no DOM, and nodes are only measured in a browser. Checked with Playwright, counting the lane's pixels inside the badges of a live user's avatars: 144 in Chrome and 156 in WebKit before, none after, in light and dark. --- resources/js/components/graph-canvas.tsx | 47 ++++++++++++++++++++ resources/js/components/live-badge.tsx | 4 +- resources/js/components/live-stream-card.tsx | 2 + resources/js/lib/graph.ts | 8 ++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/resources/js/components/graph-canvas.tsx b/resources/js/components/graph-canvas.tsx index 82350c920..7ae7a36e4 100644 --- a/resources/js/components/graph-canvas.tsx +++ b/resources/js/components/graph-canvas.tsx @@ -29,6 +29,7 @@ import type { GraphNode, LeavingRow, NodeAnimation, + Overhang, Point, } from '@/lib/graph'; import { cn } from '@/lib/utils'; @@ -337,6 +338,30 @@ function snap(value: number): number { return Math.round(value * ratio) / ratio; } +/** + * What a node marks `data-graph-overhang` as hanging off it, like a LIVE badge over the bottom of an avatar's ring, grown + * by the ring the attribute gives, in px. + */ +function overhangsOf(element: HTMLElement, box: DOMRect): Overhang[] { + return [ + ...element.querySelectorAll('[data-graph-overhang]'), + ].map((overhang) => { + const rect = overhang.getBoundingClientRect(); + const ring = Number(overhang.dataset.graphOverhang) || 0; + + return { + x: snap(rect.left - box.left - ring), + y: snap(rect.top - box.top - ring), + width: snap(rect.width + 2 * ring), + height: snap(rect.height + 2 * ring), + radius: + (Number.parseFloat( + getComputedStyle(overhang).borderTopLeftRadius, + ) || 0) + ring, + }; + }); +} + /** * The CSS animations running on a node and on the elements around it, up to the canvas, with the time they have left. */ @@ -420,11 +445,13 @@ export function GraphCanvas({ children }: { children: ReactNode }) { .filter(({ element }) => element.isConnected) .map(({ element, spec }) => { const centre = layoutCentre(element, container); + const overhangs = overhangsOf(element, box); rows.find((row) => row.element.contains(element))?.row.ids.add( spec.id, ); return { + ...(overhangs.length > 0 && { overhangs }), id: spec.id, lane: spec.lane, after: spec.after, @@ -696,6 +723,26 @@ export function GraphCanvas({ children }: { children: ReactNode }) { )} /> ))} + {nodes.flatMap((node) => + (node.overhangs ?? []).map( + (overhang, index) => ( + + row.ids.has(node.id), + ), + )} + /> + ), + ), + )} {leaving.map((row, index) => ( diff --git a/resources/js/components/live-badge.tsx b/resources/js/components/live-badge.tsx index 46de0d9a5..b45083ece 100644 --- a/resources/js/components/live-badge.tsx +++ b/resources/js/components/live-badge.tsx @@ -1,6 +1,7 @@ +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; -export function LiveBadge({ className }: { className?: string }) { +export function LiveBadge({ className, ...props }: ComponentProps<'span'>) { return ( ); } diff --git a/resources/js/lib/graph.ts b/resources/js/lib/graph.ts index eadfffee7..f929bdd44 100644 --- a/resources/js/lib/graph.ts +++ b/resources/js/lib/graph.ts @@ -15,6 +15,14 @@ export type GraphNode = Point & { dimmed?: boolean; /** On its way out, like an end dot scaling out as the thread goes on below it: linked to nothing, it only keeps its cut-out. */ isLeaving?: boolean; + /** What hangs off the node beyond its ring, like a LIVE badge, cut out of the lines too. */ + overhangs?: Overhang[]; +}; + +export type Overhang = Point & { + width: number; + height: number; + radius: number; }; export type GraphEdge = { From 2ff9c921c58f99d9a71061623131748605d7dc79 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Wed, 28 Oct 2026 22:32:50 +0100 Subject: [PATCH 2/2] fix: cut out a LIVE badge as its user goes live while the page is open The badge sits out of the layout, so adding or removing it resizes nothing, and the mutation observer only measured when a node itself came or went: the lines ran over a new badge, or left a hole where one had been, until the next measure. The observer now also measures when a mutation inside a node adds or removes an overhang. Measuring on every mutation inside a node, as suggested in review, would also fire on unrelated changes there; this keeps to the one change that moves a cut-out. --- resources/js/components/graph-canvas.tsx | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/resources/js/components/graph-canvas.tsx b/resources/js/components/graph-canvas.tsx index 7ae7a36e4..b2f0d0155 100644 --- a/resources/js/components/graph-canvas.tsx +++ b/resources/js/components/graph-canvas.tsx @@ -362,6 +362,14 @@ function overhangsOf(element: HTMLElement, box: DOMRect): Overhang[] { }); } +function isOverhang(node: Node): boolean { + return ( + node instanceof HTMLElement && + (node.dataset.graphOverhang !== undefined || + node.querySelector('[data-graph-overhang]') !== null) + ); +} + /** * The CSS animations running on a node and on the elements around it, up to the canvas, with the time they have left. */ @@ -572,7 +580,8 @@ export function GraphCanvas({ children }: { children: ReactNode }) { // Nodes also move without anything resizing, like cards trading places when the feed reorders. Other changes // inside a card, like an image done loading, move no node unless they resize the container, which the resize - // observer reports. + // observer reports. An overhang, like the LIVE badge as its user goes live, comes and goes out of the layout, + // so nothing resizes either. const mutations = new MutationObserver((records) => { const elements = [ ...[...entries.current.values()].map(({ element }) => element), @@ -581,11 +590,15 @@ export function GraphCanvas({ children }: { children: ReactNode }) { departed.current.clear(); if ( - records.some((record) => - [...record.addedNodes, ...record.removedNodes].some( - (node) => + records.some( + ({ target, addedNodes, removedNodes }) => + [...addedNodes, ...removedNodes].some((node) => elements.some((element) => node.contains(element)), - ), + ) || + ([...addedNodes, ...removedNodes].some(isOverhang) && + elements.some((element) => + element.contains(target), + )), ) ) { measureNow();