Skip to content
Open
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
13 changes: 11 additions & 2 deletions apps/electron/e2e/heatmap-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,20 @@ try {
const renderMs = performance.now() - started;
await page.screenshot({ path: resolve(output, 'heatmap-equal.png'), fullPage: true });

await page.getByLabel('Tile size').selectOption('weight');
await page.getByRole('button', { name: 'Market value', exact: true }).click();
assert.equal(await page.getByRole('button', { name: 'Market value', exact: true }).getAttribute('aria-pressed'), 'true');
assert.equal(await page.getByRole('button', { name: 'Equal', exact: true }).getAttribute('aria-pressed'), 'false');
assert.equal(await page.getByRole('button', { name: 'Market value', exact: true }).locator('svg').count(), 1);
assert.equal(await page.getByRole('button', { name: 'Equal', exact: true }).locator('svg').count(), 0);
assert.ok(Math.abs(await relativeArea() - 1.2) < 0.08, 'Weighted area follows snapshot values');
assert.ok((await page.getByTestId('heatmap-tile-AAPL').getAttribute('title')).includes('30%'));
assert.ok((await page.getByTestId('heatmap-tile-AAPL').textContent()).includes('30%'));
assert.ok((await page.getByTestId('heatmap-tile-AAPL').textContent()).includes('Apple Inc.'));
assert.ok((await page.textContent('[data-testid="heatmap-panel"]')).includes('Fixture technology'));
assert.ok((await page.textContent('[data-testid="heatmap-panel"]')).includes('3 symbols'));
await page.screenshot({ path: resolve(output, 'heatmap-weighted.png'), fullPage: true });
await page.getByRole('button', { name: 'Table', exact: true }).click();
assert.equal(await page.getByRole('button', { name: 'Table', exact: true }).getAttribute('aria-pressed'), 'true');
assert.equal(await page.getByRole('button', { name: 'Heatmap', exact: true }).getAttribute('aria-pressed'), 'false');
assert.equal(await page.getByTestId('heatmap-table').locator('tbody tr').count(), 6);
await page.getByTestId('heatmap-table').getByRole('button', { name: /Apple Inc/ }).click();
assert.ok((await page.getByTestId('heatmap-detail').textContent()).includes('30%'));
Expand Down
10 changes: 7 additions & 3 deletions docs/heatmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ short entries is excluded rather than representing a gross absolute exposure.

## Display and selection

D3's treemap groups canonical listings by industry. A fixed visible legend
shows daily percentage-change bucket boundaries. Positive/negative numbers and
unknown/stale labels supplement color. Stale and missing quotes are gray;
D3's treemap groups canonical listings by industry. A TradingView-inspired
continuous red-to-deep-gray-to-green legend is clamped at -3% and +3%.
Positive/negative numbers and unknown/stale labels supplement color. Stale and
unknown returns use separate neutral grays;
tiles use 1px inner spacing, 4px industry separation, responsive labels, white
selection outlines, and bounded hover cards with identity, move, industry,
weight, source, and market time. Stale and missing quotes are gray;
unknown returns remain null. Delayed quotes, closed sessions, provider-stale
quotes and quotes older than 15 minutes are marked stale. Demo quotes and
quotes with missing time or mismatched identity never become current data.
Expand Down
Binary file modified docs/images/heatmap/equal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/heatmap/mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/heatmap/table.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/heatmap/weighted.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 13 additions & 4 deletions packages/core/src/heatmap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ describe('heatmap value mapping', () => {
expect(heatmapHoldingValue({ symbol: 'A.US', name: '', currency: 'USD' }, 'USD', 'USD').exclusion).toBe('missing-value');
});

it('renders stale or unavailable returns gray, never as zero change', () => {
const tile = { changePercent: null, state: 'unknown' } as Pick<HeatmapTile, 'state' | 'changePercent'>;
expect(heatmapColor(tile)).toBe('#6b7280');
expect(heatmapColor({ changePercent: 0, state: 'current' })).not.toBe('#6b7280');
it('keeps stale and unknown returns distinct from the neutral zero color', () => {
expect(heatmapColor({ changePercent: null, state: 'unknown' })).toBe('#4b5563');
expect(heatmapColor({ changePercent: -4, state: 'stale' })).toBe('#6b7280');
expect(heatmapColor({ changePercent: 0, state: 'current' })).not.toBe('#4b5563');
});

it('uses a continuous capped red-to-green palette', () => {
const colors = [-5, -3, -0.5, 0, 0.5, 3, 5].map((changePercent) => heatmapColor({ changePercent, state: 'current' }));
expect(colors[0]).toBe(colors[1]);
expect(colors[5]).toBe(colors[6]);
expect(colors[0]).toBe('#f23645');
expect(colors[3]).toBe('#343a40');
expect(colors[5]).toBe('#089981');
expect(new Set(colors).size).toBe(5);
});
});
33 changes: 23 additions & 10 deletions packages/core/src/heatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,28 @@ export function heatmapHoldingValue(
return { value };
}

/** Fixed daily-move buckets, shared by the visible legend and tile rendering. */
const HEATMAP_RED = '#F23645';
const HEATMAP_NEUTRAL = '#343a40';
const HEATMAP_GREEN = '#089981';
const HEATMAP_STALE = '#6b7280';
const HEATMAP_UNKNOWN = '#4b5563';

function hexToRgb(hex: string): [number, number, number] {
return [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16)) as [number, number, number];
}

function blend(from: string, to: string, amount: number): string {
const a = hexToRgb(from);
const b = hexToRgb(to);
return `#${a.map((value, index) => Math.round(value + (b[index] - value) * amount).toString(16).padStart(2, '0')).join('')}`;
}

/** Continuous red-to-green daily-move palette, capped at +/-3%. */
export function heatmapColor(tile: Pick<HeatmapTile, 'state' | 'changePercent'>): string {
if (tile.state !== 'current' || tile.changePercent === null) return '#6b7280';
const change = tile.changePercent;
if (change <= -3) return '#9f2434';
if (change < -1) return '#c74653';
if (change < 0) return '#a8646c';
if (change === 0) return '#575c63';
if (change < 1) return '#428077';
if (change < 3) return '#18786a';
return '#075e50';
if (tile.state === 'stale') return HEATMAP_STALE;
if (tile.state !== 'current' || tile.changePercent === null) return HEATMAP_UNKNOWN;
const change = Math.max(-3, Math.min(3, tile.changePercent));
return change < 0
? blend(HEATMAP_RED, HEATMAP_NEUTRAL, (change + 3) / 3)
: blend(HEATMAP_NEUTRAL, HEATMAP_GREEN, change / 3);
}
25 changes: 23 additions & 2 deletions packages/ui/src/components/heatmap/HeatmapPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { DEFAULT_INSTRUMENT_CATALOG, type HeatmapSnapshot, type HeatmapSourceSna
import { fallbackClient, FinagentClientProvider, type FinagentClient } from '../../client';
import { installHappyDom } from '../../test/setupHappyDom';
import { makeTestI18n, I18nextProvider } from '../../test/i18nTest';
import { HeatmapPanel, heatmapRectangles } from './HeatmapPanel';
import { HeatmapPanel, heatmapRectangles, heatmapTileTextScale } from './HeatmapPanel';
import { watchlistHeatmapSource } from './heatmapSource';
import { activeSymbolAtom } from '../../atoms';

Expand Down Expand Up @@ -72,6 +72,12 @@ async function flushAsync(): Promise<void> {
}

describe('HeatmapPanel', () => {
it('uses minimum tile dimension thresholds for readable labels', () => {
expect(heatmapTileTextScale(100, 80)).toBe('large');
expect(heatmapTileTextScale(60, 40)).toBe('medium');
expect(heatmapTileTextScale(30, 20)).toBe('small');
expect(heatmapTileTextScale(17, 40)).toBe('hidden');
});
it('keeps a 100-symbol versioned snapshot compact enough for IPC', () => {
const source = watchlistHeatmapSource(Array.from({ length: 100 }, (_, index) => `T${index}.US`));
expect(source.entries).toHaveLength(100);
Expand All @@ -98,6 +104,7 @@ describe('HeatmapPanel', () => {
console.info(`heatmap renderer benchmark: 100 fixture securities, ${layout.tiles.length} rectangles, ${(performance.now() - started).toFixed(1)}ms`);
expect(layout.tiles).toHaveLength(100);
expect(layout.groups).toHaveLength(10);
expect(layout.groups.every((group) => group.count === 10 && group.height > 30)).toBe(true);
expect(layout.tiles.every((item) => item.x >= 0 && item.y >= 0 && item.x + item.width <= 960.01 && item.y + item.height <= 360.01)).toBe(true);
});

Expand Down Expand Up @@ -154,14 +161,28 @@ describe('HeatmapPanel', () => {
it('shows ratio weights as percentages in tooltips, details, and the same snapshot table', async () => {
const panel = await mountPanel(createClient(() => ({ ...snapshot, tiles: [{ ...tile, weight: 0.25 }] })));
const button = panel.host.querySelector('[data-testid="heatmap-tile-AAPL"]') as HTMLButtonElement;
expect(button.title).toContain('25%');
expect(button.textContent).toContain('25%');
await act(async () => button.click());
expect(panel.host.querySelector('[data-testid="heatmap-detail"]')?.textContent).toContain('25%');
await act(async () => (panel.host.querySelector('[aria-label="Table"]') as HTMLButtonElement).click());
expect(panel.host.querySelector('[data-testid="heatmap-table"]')?.textContent).toContain('25%');
await panel.close();
});

it('presents industry hierarchy and tile-size modes as readable controls', async () => {
const nvidia = DEFAULT_INSTRUMENT_CATALOG.find((item) => item.instrumentId === 'XNAS:NVDA')!;
const tiles = [tile, { ...tile, instrument: nvidia, workspaceSymbol: 'NVDA.US' }];
const panel = await mountPanel(createClient(() => ({ ...snapshot, tiles })));
expect(panel.host.textContent).toContain('Technology');
expect(panel.host.textContent).toContain('2 symbols');
const sizeGroup = panel.host.querySelector('[role="group"]')!;
expect(sizeGroup.querySelector('[aria-pressed="true"]')?.textContent).toBe('Equal');
expect(sizeGroup.querySelectorAll('button[aria-pressed="true"] svg')).toHaveLength(1);
expect((sizeGroup.querySelector('button[aria-pressed="false"]') as HTMLButtonElement).disabled).toBe(true);
expect(panel.host.querySelectorAll('[data-testid^="heatmap-tile-"]')).toHaveLength(2);
await panel.close();
});

it('hides previous membership during a source change and ignores a cancelled late response', async () => {
const pending = new Map<string, (data: HeatmapSnapshot) => void>();
const cancelled: string[] = [];
Expand Down
Loading