Skip to content

[profiler] Add the diagram palettes, stylesheet and corner chips - #6944

Open
Karakatiza666 wants to merge 1 commit into
redesign-profiler-diagram-3from
redesign-profiler-diagram-4
Open

[profiler] Add the diagram palettes, stylesheet and corner chips#6944
Karakatiza666 wants to merge 1 commit into
redesign-profiler-diagram-3from
redesign-profiler-diagram-4

Conversation

@Karakatiza666

@Karakatiza666 Karakatiza666 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Part 4 of 15 of #6895, split one commit per PR. Based on redesign-profiler-diagram-3; merge in order.

The stack (this is 4 of 15)
  1. [profiler] Remove the unused hierarchical table #6941 [profiler] Remove the unused hierarchical table
  2. [profiler] Select the whole SQL range a node came from #6942 [profiler] Select the whole SQL range a node came from
  3. [profiler] Count the primitive operators inside every region #6943 [profiler] Count the primitive operators inside every region
  4. [profiler] Add the diagram palettes, stylesheet and corner chips #6944 [profiler] Add the diagram palettes, stylesheet and corner chips <-- this PR
  5. [profiler] Size an expanded region for its name and its counter #6945 [profiler] Size an expanded region for its name and its counter
  6. [profiler] Draw the diagram from the palette-driven stylesheet #6946 [profiler] Draw the diagram from the palette-driven stylesheet
  7. [profiler] Draw a collapsed region nested inside an expanded one #6947 [profiler] Draw a collapsed region nested inside an expanded one
  8. [profiler] Paint a node's id and its operator as two text runs #6948 [profiler] Paint a node's id and its operator as two text runs
  9. [profiler] Mark the node the metrics are about, and trace its edges #6949 [profiler] Mark the node the metrics are about, and trace its edges
  10. [profiler] Show a picture of the circuit on the minimap, and steer from it #6950 [profiler] Show a picture of the circuit on the minimap, and steer from it
  11. [profiler] Move the diagram's lifecycle reactions into observers #6951 [profiler] Move the diagram's lifecycle reactions into observers
  12. [profiler] Make the corner chips pressable #6952 [profiler] Make the corner chips pressable
  13. [profiler] Follow the application theme #6953 [profiler] Follow the application theme
  14. [profiler] Add a browser harness for the diagram, and pin what it paints #6954 [profiler] Add a browser harness for the diagram, and pin what it paints
  15. [profiler] Pin what a pointer on the diagram does #6955 [profiler] Pin what a pointer on the diagram does

The look of the diagram was a stylesheet literal inside
CytographRendering, with every color, size and radius spelled out at its
only point of use and no second palette possible. Two new modules replace
it, wired up in a later commit:

diagramTheme.ts geometry constants, the light and dark palettes, and
buildGraphStyle(theme), which builds a cytoscape
stylesheet from one of them
chips.ts the corner chips drawn on a node: an SVG for the "this
node has SQL behind it" mark and one for the count of
operators a region hides, plus the metrics both the
stylesheet and the hit testing size them by

The two import each other: a chip is drawn from the palette, and the
stylesheet needs the background-image slots the chips are placed in.

Both suites drive a headless cytoscape instance, which resolves styles
without a renderer. That is what pins the mechanisms that fail silently:
the per-node chip image list, the taxi edge routing, and the draw order
that keeps an edge from crossing a region's chips.

Describe Manual Test Plan

Nothing to look at: the two modules have no caller until part 6.

Verified at this commit, not just at the tip of the stack: checked out detached with js-packages/profiler-lib/dist deleted and rebuilt from this commit's source, then profiler-lib bun run check and bun run test, and profiler-layout bun run check and bun run test (all three vitest projects, browser suites included). All four green.

Checklist

  • Unit tests added/updated
  • Integration tests added/updated
  • Documentation updated
  • Changelog updated

Breaking Changes?

Mark if you think the answer is yes for any of these components:

  • OpenAPI / REST HTTP API / feldera-types / manager
  • Feldera SQL (Syntax, Semantics)
  • feldera-sqllib (incl. dependencies fxp, etc.)
  • Python SDK
  • fda (CLI arguments)
  • Adapters (including configuration)
  • Storage Format / Checkpoints
  • Others (specify)

Describe Incompatible Changes

None. The change is confined to js-packages/.

Comment on lines +172 to +180
export function formatLeafCount(count: number): string {
if (count < 1000) {
return String(count);
}
const [divisor, suffix] = count < 1_000_000 ? [1000, 'K'] : [1_000_000, 'M'];
const scaled = count / divisor!;
return `${scaled < 10 ? Math.round(scaled * 10) / 10 : Math.round(scaled)}${suffix}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatLeafCount never rolls over to the next unit, so the label can exceed the 5 glyphs BADGE_CANVAS_WIDTH is sized for. Verified by running it:

count label glyphs
999_499 999K 4
999_500 1000K 5 (should be 1M)
1_000_000_000 1000M 5
12_000_000_000 12000M 6

badgePillWidth clamps the pill with Math.min, but glyphRun is still laid out at label.length * CHIP_GLYPH_WIDTH, so past 6 glyphs the count runs off the pill and is clipped by the canvas. Rounding first and then re-checking the divisor fixes both (999_500 → 1M, 1e9 → 1000M capped or a B suffix). Neither the 1000K case nor a >= 1e9 count is in the tests.

Comment on lines +76 to +81
it('keeps the badge pill inside its canvas for the widest label', () => {
// The badge canvas is a fixed size in the stylesheet; a pill wider than the canvas would
// be clipped, and a taller one would distort.
const svg = decode(nodeChips(false, 999_999_999, 'light')[1]!)
expect(attr(svg, 'rect', 'width')).toBeLessThanOrEqual(attr(svg, 'svg', 'width'))
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion cannot fail: badgePillWidth is Math.min(BADGE_CANVAS_WIDTH, …), so the rect is ≤ the canvas for any label, widest or not. The invariant that actually holds the chip together is that the glyph run stays inside the pill — text x + textLength <= pill.x + pill.width. Asserted that way it would catch the overflow described on formatLeafCount.

// Measured by `labelWidth` rather than by cytoscape, so the room a counter chip needs
// can be added to it. An expanded region ignores this and its height both, and sizes
// itself to its children.
'width': 'data(text_width)',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both data() mappings in this sheet — width: data(text_width) here and min-width: data(min_width) at L238 — are untested, and cytoscape fails them silently. Every node in diagramTheme.test.ts is built without those two fields, and headless cytoscape resolves the node to its default width of 30 rather than erroring:

no text_width  -> width = 30
with text_width: 120 -> width = 120

So a rename or a typo in either key ships as "every node is 30px wide" with the suite green — the same class of silent failure the file's header comment sets out to pin. One node carrying text_width/min_width and an assertion on the resolved width would close it.

Comment on lines +58 to +70
export function labelWidth(text: string): number {
if (labelContext === undefined) {
labelContext = typeof document === 'undefined'
? null
: document.createElement('canvas').getContext('2d');
if (labelContext !== null) {
labelContext.font = labelFont();
}
}
if (labelContext === null) {
return Math.ceil(text.length * LABEL_GLYPH_FALLBACK);
}
return Math.ceil(labelContext.measureText(text).width);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

labelWidth is the one piece of real logic in this module — two branches, a memoized context, a fallback constant — and it has no test. It is even imported into diagramTheme.test.ts (L23, along with REGION_PADDING at L29) and never used; tsconfig.json excludes *.test.ts, so noUnusedLocals does not catch the dead imports.

Worth pinning at least: the no-DOM fallback (text.length * LABEL_GLYPH_FALLBACK, ceiled), monotonicity in the length of the text, and the empty string. The measureText branch needs the browser project.

Comment on lines +276 to +290
const palette = DIAGRAM_PALETTES.light
const [border, edge] = [palette.border, palette.edge]
try {
palette.edge = '#123456'
const cy = graph('light')
expect(cy.$id('e').style('line-color')).toBe(hexToRgb('#123456'))
expect(cy.$id('plain').style('border-color')).toBe(hexToRgb(border))

palette.border = '#654321'
const repainted = graph('light')
expect(repainted.$id('plain').style('border-color')).toBe(hexToRgb('#654321'))
expect(repainted.$id('e').style('line-color')).toBe(hexToRgb('#123456'))
} finally {
palette.border = border
palette.edge = edge

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mutating the exported DIAGRAM_PALETTES singleton to prove the two entries are distinct. The finally restores it and vitest isolates files, so it is not flaky today, but it is a trap for whoever adds the next test: chips.ts caches chip URIs keyed on the theme name, so a palette repainted mid-suite yields chips built from the old colors, and ARCHITECTURE.md lists "no global state or singletons" for this package.

Making DiagramPalette's fields readonly and having buildGraphStyle accept a DiagramPalette (with buildGraphStyle(DIAGRAM_PALETTES[theme]) at the call site) buys the same test with a throwaway palette and no shared mutation.

Comment on lines +209 to +225
export function nodeChips(
hasSource: boolean,
leafCount: number,
theme: DiagramTheme,
counter: CounterGlyph = 'count'
): Array<string> {
const count = formatLeafCount(leafCount);
return [
hasSource ? cached(`code:${theme}`, () => codeChip(theme)) : CHIP_NONE,
leafCount === 0
? CHIP_NONE
: counter === 'count'
? cached(`count:${theme}:${count}`, () => counterChip(count, 'count', theme))
: cached(`${counter}:${theme}:${count.length}`,
() => counterChip(count, counter, theme)),
];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two smaller things here:

  • The return type is Array<string>, but every consumer — the stylesheet's nine per-slot lists, chipButtons.ts, and the tests — depends on it being exactly two entries. [string, string] makes that contract compile-checked and drops the ! assertions scattered through both test files.
  • cache is a module-level Map that is never cleared. Bounded by construction (≈1000 count labels × 2 themes × 3 glyphs), so not a leak, but it is process-global state in a library documented as having none — a field on the renderer, or an explicitly-documented memo, would be easier to reason about.

@mythical-fred-oss

Copy link
Copy Markdown

Reviewed against feldera-pr-checks; no gate fails. Two new modules, no caller yet, no user-visible surface, no deps, no unsafe, no workflow or licensing concerns — docs/changelog boxes are correctly unchecked, and the manual test plan is specific about what was verified at this commit rather than at the tip of the stack.

Ran, from js-packages/profiler-lib after a root bun install --frozen-lockfile:

command result
bun run test 157 passed / 5 files, 530 ms
bun run check (tsc --noEmit) clean

Nothing timing-based, ordering-dependent or external in the new suites, so no flakiness risk; the one shared-state mutation is restored in a finally and vitest isolates per file. Correctness/security/perf pass: only numeric and constant text reaches the SVG (no injection path), the chip cache is bounded by its key space, and chips.tsdiagramTheme.ts is a genuine import cycle that resolves only because neither module reads the other at evaluation time — a third module for the shared geometry constants would remove that standing hazard and let BADGE_ROW_HEIGHT be derived instead of hand-tuned and pinned by a test. QA gaps are in the inline comments: unit rollover in formatLeafCount (999_500 → "1000K", 1e9 → "1000M", 1.2e10 → "12000M" overflowing the pill), a vacuous widest-label assertion, and both data() mappings resolving silently to cytoscape's default width when the key is absent.

@Karakatiza666
Karakatiza666 force-pushed the redesign-profiler-diagram-4 branch from 39d44fc to 7ca1adc Compare August 26, 2026 07:04
The look of the diagram was a stylesheet literal inside
`CytographRendering`, with every color, size and radius spelled out at its
only point of use and no second palette possible. Two new modules replace
it, wired up in a later commit:

  diagramTheme.ts  geometry constants, the light and dark palettes, and
                   `buildGraphStyle(theme)`, which builds a cytoscape
                   stylesheet from one of them
  chips.ts         the corner chips drawn on a node: an SVG for the "this
                   node has SQL behind it" mark and one for the count of
                   operators a region hides, plus the metrics both the
                   stylesheet and the hit testing size them by

The two import each other: a chip is drawn from the palette, and the
stylesheet needs the background-image slots the chips are placed in.

Both suites drive a headless cytoscape instance, which resolves styles
without a renderer. That is what pins the mechanisms that fail silently:
the per-node chip image list, the taxi edge routing, and the draw order
that keeps an edge from crossing a region's chips.

Signed-off-by: Karakatiza666 <bulakh.96@gmail.com>
@Karakatiza666
Karakatiza666 force-pushed the redesign-profiler-diagram-4 branch from 7ca1adc to 90d19db Compare August 26, 2026 07:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant