diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md
index 1cb3fdc..add000d 100644
--- a/docs/binance-orderbook-trade-development.md
+++ b/docs/binance-orderbook-trade-development.md
@@ -139,6 +139,23 @@ The userscript installs `core/binance-native-depth-source.js` at `document-start
The session must stop and invalidate old work on symbol change, non-trading routes, hidden documents, and `pagehide`. The overlay canvas uses `pointer-events: none`; only its compact collapse control may receive pointer input. Do not connect this visualization book to ladder pricing or any trading decision.
+Compact depth labels are painted inside the existing 132px canvas. Each pixel row
+keeps its largest cumulative quantity for the bar and separately sums all real
+level quantities for its label, retaining the full minimum-to-maximum price band.
+Labels identify band quantities in the native book's units, not cumulative totals
+or changes over time. Candidates must add at least 8 CSS pixels of depth at the
+current visible scale; larger quantities win, with at most two labels per side.
+Both sides share collision checks against other labels, the latest-trade divider,
+the collapse control, and visible status text.
+
+Each label uses 11px text, a 16px height, and its measured width up to 88px. Price
+and compact quantity are shown when they fit; a long price band uses quantity
+alone instead of truncating it or pretending it belongs to a single price.
+Quantities use up to three significant digits and omit repeated asset names.
+Labels stay within the canvas, follow inverted scales, and are omitted when no
+space remains. They repaint and clear with the depth bars without adding DOM
+nodes, listeners, timers, or cross-frame state.
+
## Symbol Identity
`src/shared/binance-symbol.js` owns the Binance identifier character contract:
diff --git a/e2e/binance-orderbook/helpers/depth-profile-fixture.js b/e2e/binance-orderbook/helpers/depth-profile-fixture.js
new file mode 100644
index 0000000..98f7305
--- /dev/null
+++ b/e2e/binance-orderbook/helpers/depth-profile-fixture.js
@@ -0,0 +1,211 @@
+import { createCancelScenario } from '../scenarios/cancel-current-symbol.js';
+import { openUserscriptScenario } from './userscript-page.js';
+
+export const DEPTH_PROFILE_SELECTOR = '#jh-binance-depth-profile';
+export const DEPTH_LABEL_SYMBOL = 'LSKUSDT';
+export const DEPTH_LABEL_LEVELS = {
+ asks: [
+ ['1.55', '5000'],
+ ['1.7', '8000'],
+ ['1.7003', '12000'],
+ ['1.8', '3800000'],
+ ['2', '2400000'],
+ ],
+ bids: [['1.49', '12000'], ['1.45', '8000'], ['1.3', '620000']],
+};
+
+/** Observe only the final userscript canvas frame; native drawing still runs. */
+function installDepthLabelProbe() {
+ const state = {
+ drawing: { serial: 0, rectangles: [], texts: [] },
+ socketCount: 0,
+ chartClicks: 0,
+ chartReady: false,
+ updateId: 102,
+ };
+ window.__DEPTH_LABEL_FIXTURE__ = state;
+ const prototype = CanvasRenderingContext2D.prototype;
+ const belongsToDepthProfile = (context) => (
+ context.canvas.matches('#jh-binance-depth-profile .jh-depth-profile-canvas')
+ );
+ const originalClear = prototype.clearRect;
+ prototype.clearRect = function (...args) {
+ const result = Reflect.apply(originalClear, this, args);
+ if (belongsToDepthProfile(this)) {
+ state.drawing = { serial: state.drawing.serial + 1, rectangles: [], texts: [] };
+ }
+ return result;
+ };
+ const originalFillRect = prototype.fillRect;
+ prototype.fillRect = function (x, y, width, height) {
+ const result = Reflect.apply(originalFillRect, this, [x, y, width, height]);
+ if (belongsToDepthProfile(this)) {
+ state.drawing.rectangles.push({ x, y, width, height, fillStyle: this.fillStyle });
+ }
+ return result;
+ };
+ const originalFillText = prototype.fillText;
+ prototype.fillText = function (...args) {
+ const result = Reflect.apply(originalFillText, this, args);
+ if (belongsToDepthProfile(this)) {
+ const [text, x, y] = args;
+ state.drawing.texts.push({
+ text, x, y,
+ width: this.measureText(text).width,
+ font: this.font,
+ fillStyle: this.fillStyle,
+ });
+ }
+ return result;
+ };
+
+ // The generated native adapter observes this page-owned socket without networking.
+ window.WebSocket = class extends EventTarget {
+ constructor() {
+ super();
+ state.socketCount += 1;
+ }
+ };
+}
+
+function readChartLayoutInPage() {
+ const chart = document.querySelector('.chart-widget-root');
+ const frame = chart.querySelector('iframe');
+ const axis = frame.contentDocument.querySelector('.chart-markup-table.price-axis-container');
+ const box = (element) => {
+ const { x, y, width, height } = element.getBoundingClientRect();
+ return { x, y, width, height };
+ };
+ return { chart: box(chart), frame: box(frame), axis: box(axis) };
+}
+
+export async function readDepthChartLayout(page) {
+ return page.evaluate(readChartLayoutInPage);
+}
+
+export async function readDepthDrawing(page) {
+ return page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.drawing);
+}
+
+/**
+ * Mount a local TradingView geometry contract and feed the generated userscript's
+ * real native-depth adapter. Returned state and the chart locator also support
+ * screenshot inspection without any production connection.
+ */
+export async function openDepthLabelScenario(page, {
+ levels = DEPTH_LABEL_LEVELS,
+ currentPrice = 1.5,
+} = {}) {
+ await page.route('**/*', (route) => route.abort('blockedbyclient'));
+ const evidence = await openUserscriptScenario(page, createCancelScenario({
+ name: 'depth-profile-compact-labels',
+ currentSymbol: DEPTH_LABEL_SYMBOL,
+ }), {
+ beforeOrderbook: `(${installDepthLabelProbe.toString()})();`,
+ });
+ const snapshotRequests = [];
+ await page.route('https://www.binance.com/fapi/v1/rpiDepth**', async (route) => {
+ const url = new URL(route.request().url());
+ snapshotRequests.push({
+ symbol: url.searchParams.get('symbol'),
+ limit: url.searchParams.get('limit'),
+ });
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ lastUpdateId: 101, ...levels }),
+ });
+ });
+
+ await page.evaluate(async ({ currentPrice, symbol }) => {
+ const previousFrame = document.querySelector('.chart-widget-root iframe');
+ const chartApi = previousFrame.contentWindow.tradingViewApi;
+ const host = document.createElement('div');
+ host.id = 'depth-chart-fixture-host';
+ host.style.cssText = 'position:relative;height:260px';
+ const wrapper = document.createElement('div');
+ wrapper.style.height = '100%';
+ const frame = document.createElement('iframe');
+ frame.title = 'TradingView depth label fixture';
+ frame.srcdoc = `
+
+
+ 2.22.0
+ 1.81.6
+ 1.41.2
+
+ `;
+ const loaded = new Promise((resolve) => frame.addEventListener('load', resolve, { once: true }));
+ wrapper.append(frame);
+ host.append(wrapper);
+ previousFrame.replaceWith(host);
+ await loaded;
+ const surface = frame.contentDocument.querySelector('#depth-chart-surface');
+ surface.querySelector('span').textContent = `${symbol} · Local depth fixture`;
+ surface.addEventListener('click', () => { window.__DEPTH_LABEL_FIXTURE__.chartClicks += 1; });
+ const tradeList = document.querySelector('.tradew-tradelist');
+ tradeList.querySelectorAll('.price.emit-price').forEach((node) => {
+ node.textContent = currentPrice === null ? '—' : String(currentPrice);
+ });
+ const scale = {
+ coordinateToPrice: (y) => 2.2 - y / 260,
+ getVisiblePriceRange: () => ({ from: 1.2, to: 2.2 }),
+ getMode: () => 0,
+ isInverted: () => false,
+ };
+ frame.contentWindow.tradingViewApi = {
+ ...chartApi,
+ activeChart: () => ({
+ hasModel: () => window.__DEPTH_LABEL_FIXTURE__.chartReady,
+ getAllPanesHeight: () => [260],
+ getPanes: () => [{ getMainSourcePriceScale: () => scale }],
+ }),
+ };
+ }, { currentPrice, symbol: DEPTH_LABEL_SYMBOL });
+ const initialLayout = await readDepthChartLayout(page);
+ await page.evaluate(() => {
+ window.__DEPTH_LABEL_FIXTURE__.chartReady = true;
+ window.dispatchEvent(new Event('resize'));
+ });
+ await page.locator(`${DEPTH_PROFILE_SELECTOR} canvas`).waitFor({ state: 'visible' });
+
+ await page.evaluate(async (symbol) => {
+ const state = window.__DEPTH_LABEL_FIXTURE__;
+ state.socket = new WebSocket('wss://depth-label-fixture.invalid/ws');
+ const response = fetch(`/fapi/v1/rpiDepth?${new URLSearchParams({ symbol, limit: '1000' })}`);
+ state.socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({
+ stream: `${symbol.toLowerCase()}@rpiDepth@500ms`,
+ data: { e: 'depthUpdate', s: symbol, st: 1, U: 100, u: 102, pu: 99, b: [], a: [] },
+ }) }));
+ await (await response).json();
+ }, DEPTH_LABEL_SYMBOL);
+ await page.waitForFunction(() => (
+ window.__TM_CLOSE_LONG_DEBUG__.nativeDepthState.status.status === 'ready'
+ ));
+ return { ...evidence, snapshotRequests, initialLayout };
+}
+
+export async function emitDepthLabelUpdate(page, { asks, bids }) {
+ await page.evaluate(({ asks, bids, symbol }) => {
+ const state = window.__DEPTH_LABEL_FIXTURE__;
+ const previousUpdateId = state.updateId;
+ state.updateId += 1;
+ state.socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({
+ stream: `${symbol.toLowerCase()}@rpiDepth@500ms`,
+ data: {
+ e: 'depthUpdate', s: symbol, st: 1,
+ U: state.updateId, u: state.updateId, pu: previousUpdateId,
+ b: bids, a: asks,
+ },
+ }) }));
+ }, { asks, bids, symbol: DEPTH_LABEL_SYMBOL });
+}
diff --git a/e2e/binance-orderbook/specs/depth-profile-labels.pw.js b/e2e/binance-orderbook/specs/depth-profile-labels.pw.js
new file mode 100644
index 0000000..34b2246
--- /dev/null
+++ b/e2e/binance-orderbook/specs/depth-profile-labels.pw.js
@@ -0,0 +1,155 @@
+import { test, expect } from '../test.js';
+import { readFixtureState } from '../helpers/userscript-page.js';
+import {
+ DEPTH_LABEL_SYMBOL,
+ DEPTH_PROFILE_SELECTOR,
+ emitDepthLabelUpdate,
+ openDepthLabelScenario,
+ readDepthChartLayout,
+ readDepthDrawing,
+} from '../helpers/depth-profile-fixture.js';
+
+const labelTexts = async (page) => (await readDepthDrawing(page)).texts.map(({ text }) => text).sort();
+const backgrounds = (drawing) => drawing.rectangles.filter(({ height }) => height === 16);
+const intersects = (left, right) => (
+ left.x < right.x + right.width && left.x + left.width > right.x
+ && left.y < right.y + right.height && left.y + left.height > right.y
+);
+
+async function expectCompactLabelGeometry(page, { currentPriceY = null } = {}) {
+ const drawing = await readDepthDrawing(page);
+ const boxes = backgrounds(drawing);
+ const canvas = await page.locator(`${DEPTH_PROFILE_SELECTOR} canvas`).boundingBox();
+ const toggle = await page.locator(`${DEPTH_PROFILE_SELECTOR} button`).boundingBox();
+ const toggleInCanvas = { ...toggle, x: toggle.x - canvas.x, y: toggle.y - canvas.y };
+ expect(canvas.width).toBe(132);
+ expect(canvas.height).toBe(260);
+ expect(boxes).toHaveLength(drawing.texts.length);
+ expect(boxes.length).toBeGreaterThan(0);
+ expect(boxes.length).toBeLessThanOrEqual(4);
+ for (let index = 0; index < boxes.length; index += 1) {
+ const box = boxes[index];
+ const text = drawing.texts[index];
+ expect(box.width).toBeLessThanOrEqual(88);
+ expect(box.x).toBeGreaterThanOrEqual(0);
+ expect(box.x + box.width).toBeLessThanOrEqual(canvas.width);
+ expect(box.y).toBeGreaterThanOrEqual(0);
+ expect(box.y + box.height).toBeLessThanOrEqual(canvas.height);
+ expect(text.font).toMatch(/^11px /);
+ expect(text.width + 6).toBeLessThanOrEqual(box.width + 0.01);
+ expect(text.x).toBeCloseTo(box.x + 3, 6);
+ expect(intersects(box, toggleInCanvas)).toBe(false);
+ if (currentPriceY !== null) {
+ expect(box.y + box.height <= currentPriceY || box.y >= currentPriceY + 1).toBe(true);
+ }
+ for (const other of boxes.slice(index + 1)) expect(intersects(box, other)).toBe(false);
+ }
+ const alpha = await page.locator(`${DEPTH_PROFILE_SELECTOR} canvas`).evaluate((element, boxes) => (
+ boxes.map((box) => element.getContext('2d').getImageData(
+ Math.floor((box.x + box.width - 2) * devicePixelRatio),
+ Math.floor((box.y + box.height - 2) * devicePixelRatio), 1, 1,
+ ).data[3])
+ ), boxes);
+ expect(alpha).toEqual(boxes.map(() => 255));
+ return { drawing, boxes, canvas };
+}
+
+async function expectIsolatedReadOnlyFixture(page, evidence) {
+ expect(evidence.errors).toEqual([]);
+ expect(evidence.snapshotRequests).toEqual([{ symbol: DEPTH_LABEL_SYMBOL, limit: '1000' }]);
+ expect(await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.socketCount)).toBe(1);
+ const state = await readFixtureState(page);
+ expect(state.orders).toEqual([]);
+ expect(state.events.filter(({ type }) => type === 'order-submitted' || type === 'cancel-requested'))
+ .toEqual([]);
+}
+
+async function attachChart(page, testInfo, name) {
+ const path = testInfo.outputPath(name);
+ await page.locator('.chart-widget-root').screenshot({ path });
+ await testInfo.attach(name, {
+ path,
+ contentType: 'image/png',
+ });
+}
+
+test('compact depth quantities preserve cumulative bars, chart layout and click-through', async ({ page }, testInfo) => {
+ const evidence = await openDepthLabelScenario(page);
+ await expect.poll(() => labelTexts(page)).toEqual(['1.3 · 620K', '1.8 · 3.8M', '2 · 2.4M']);
+ const { drawing, boxes, canvas } = await expectCompactLabelGeometry(page, { currentPriceY: 182 });
+ const largeAskBar = drawing.rectangles.find((rectangle) => (
+ rectangle.height === 1 && rectangle.fillStyle === '#f6465d' && rectangle.y === 104
+ ));
+ expect(largeAskBar.width).toBeCloseTo(3_825_000 / 6_225_000 * 132, 6);
+ expect(largeAskBar.x + largeAskBar.width).toBeCloseTo(132, 6);
+ expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout);
+ expect(evidence.initialLayout.frame).toMatchObject({ width: 698, height: 260 });
+ expect(evidence.initialLayout.axis).toMatchObject({ width: 60, height: 260 });
+ expect(await page.locator(DEPTH_PROFILE_SELECTOR).evaluate((root) => ({
+ pointerEvents: getComputedStyle(root).pointerEvents,
+ canvasPointerEvents: getComputedStyle(root.querySelector('canvas')).pointerEvents,
+ childTags: [...root.children].map(({ tagName }) => tagName),
+ }))).toEqual({ pointerEvents: 'none', canvasPointerEvents: 'none', childTags: ['CANVAS', 'BUTTON', 'DIV'] });
+
+ const label = boxes[0];
+ await page.mouse.click(canvas.x + label.x + label.width / 2, canvas.y + label.y + label.height / 2);
+ expect(await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.chartClicks)).toBe(1);
+ await expectIsolatedReadOnlyFixture(page, evidence);
+ await attachChart(page, testInfo, 'compact-depth-labels.png');
+});
+
+test('pixel-row quantities remain readable with no latest price and a wide price band', async ({ page }, testInfo) => {
+ const evidence = await openDepthLabelScenario(page, {
+ currentPrice: null,
+ levels: {
+ asks: [['1.8', '1500000'], ['1.8008', '2300000'], ['2', '2400000'], ['2.1', '2000000']],
+ bids: [['1.49', '600000'], ['1.4', '700000'], ['1.3', '900000']],
+ },
+ });
+ await expect.poll(() => labelTexts(page)).toEqual(['1.3 · 900K', '1.4 · 700K', '2 · 2.4M', '3.8M']);
+ const { drawing } = await expectCompactLabelGeometry(page);
+ await expect(page.locator('.tradew-tradelist .price.emit-price').first()).toHaveText('—');
+ const aggregatedBar = drawing.rectangles.find((rectangle) => (
+ rectangle.height === 1 && rectangle.fillStyle === '#f6465d' && rectangle.y === 104
+ ));
+ expect(aggregatedBar.width).toBeCloseTo(3_800_000 / 8_200_000 * 132, 6);
+ expect(drawing.rectangles.filter((rectangle) => (
+ rectangle.height === 1 && rectangle.fillStyle === '#f6465d' && rectangle.y === 104
+ ))).toHaveLength(1);
+ expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout);
+ await expectIsolatedReadOnlyFixture(page, evidence);
+ await attachChart(page, testInfo, 'aggregated-depth-labels.png');
+});
+
+test('native updates, collapse and disconnect remove stale depth label pixels', async ({ page }, testInfo) => {
+ const evidence = await openDepthLabelScenario(page);
+ await expect.poll(() => labelTexts(page)).toContain('1.8 · 3.8M');
+ await emitDepthLabelUpdate(page, {
+ asks: [['1.8', '1200'], ['2', '0'], ['2.05', '2600000']],
+ bids: [['1.3', '0'], ['1.35', '900000']],
+ });
+ const updatedTexts = ['1.35 · 900K', '2.05 · 2.6M'];
+ await expect.poll(() => labelTexts(page)).toEqual(updatedTexts);
+ await expectCompactLabelGeometry(page, { currentPriceY: 182 });
+ await attachChart(page, testInfo, 'updated-depth-labels.png');
+
+ const root = page.locator(DEPTH_PROFILE_SELECTOR);
+ await root.locator('button').click();
+ await expect(root).toHaveAttribute('data-expanded', 'false');
+ await expect(root.locator('canvas')).toBeHidden();
+ await expect.poll(() => labelTexts(page)).toEqual([]);
+ expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout);
+ await root.locator('button').click();
+ await expect(root).toHaveAttribute('data-expanded', 'true');
+ await expect.poll(() => labelTexts(page)).toEqual(updatedTexts);
+ expect(await readDepthChartLayout(page)).toEqual(evidence.initialLayout);
+
+ await page.evaluate(() => window.__DEPTH_LABEL_FIXTURE__.socket.dispatchEvent(new Event('close')));
+ await expect(root.locator('.jh-depth-profile-status')).toHaveText('重新连接深度');
+ await expect.poll(() => labelTexts(page)).toEqual([]);
+ expect(await root.locator('canvas').evaluate((canvas) => (
+ canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data.every((value) => value === 0)
+ ))).toBe(true);
+ await expectIsolatedReadOnlyFixture(page, evidence);
+ await attachChart(page, testInfo, 'cleared-depth-labels.png');
+});
diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js
index ce33da5..84c2b66 100644
--- a/scripts/binance-orderbook-trade.user.js
+++ b/scripts/binance-orderbook-trade.user.js
@@ -3,7 +3,7 @@
// @namespace binance.orderbook.trade
// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
-// @version 2.7.206
+// @version 2.7.207
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -4138,6 +4138,17 @@
var PRICE_AXIS_SELECTOR = ".chart-markup-table.price-axis-container";
var PRICE_COORDINATE_SEARCH_STEPS = 13;
var GEOMETRY_TOLERANCE_PX = 1;
+ var DEPTH_LABEL_MAX_WIDTH = 88;
+ var DEPTH_LABEL_HEIGHT = 16;
+ var DEPTH_LABEL_PADDING = 3;
+ var DEPTH_LABEL_GAP = 2;
+ var DEPTH_LABEL_MIN_STEP = 8;
+ var DEPTH_LABELS_PER_SIDE = 2;
+ var DEPTH_QUANTITY_FORMAT = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ maximumSignificantDigits: 3,
+ useGrouping: false
+ });
function hasVisibleBox2(element) {
if (!element?.getClientRects().length) return false;
const rect = element.getBoundingClientRect();
@@ -4232,6 +4243,8 @@
style.id = STYLE_ID;
style.textContent = `
#${DEPTH_PROFILE_ID} {
+ --jh-depth-label-background: var(--color-BasicBg, #fff);
+ --jh-depth-label-color: var(--color-SecondaryText, #474d57);
position: absolute;
z-index: 3;
top: 0;
@@ -4367,8 +4380,16 @@
if (!Number.isFinite(coordinate)) continue;
const y = Math.max(0, Math.min(lastRow, Math.round(coordinate)));
const current = buckets.get(y);
- if (!current || level.cumulative > current.cumulative) {
- buckets.set(y, { ...level, y });
+ if (!current) {
+ buckets.set(y, { ...level, y, minPrice: level.price, maxPrice: level.price });
+ continue;
+ }
+ current.quantity += level.quantity;
+ current.minPrice = Math.min(current.minPrice, level.price);
+ current.maxPrice = Math.max(current.maxPrice, level.price);
+ if (level.cumulative > current.cumulative) {
+ current.price = level.price;
+ current.cumulative = level.cumulative;
}
}
return [...buckets.values()].sort((left, right) => left.y - right.y);
@@ -4388,6 +4409,71 @@
}
return maximum;
}
+ function depthLabelBoxesOverlap(left, right) {
+ return left.x < right.x + right.width + DEPTH_LABEL_GAP && left.x + left.width + DEPTH_LABEL_GAP > right.x && left.y < right.y + right.height + DEPTH_LABEL_GAP && left.y + left.height + DEPTH_LABEL_GAP > right.y;
+ }
+ function depthLabelObstacles(root, canvasRect) {
+ const controls = [root.querySelector("[data-depth-profile-toggle]")];
+ const status = root.querySelector(".jh-depth-profile-status");
+ if (status.textContent) controls.push(status);
+ return controls.map((element) => element.getBoundingClientRect()).filter((rect) => rect.width > 0 && rect.height > 0).map((rect) => ({
+ x: rect.left - canvasRect.left,
+ y: rect.top - canvasRect.top,
+ width: rect.width,
+ height: rect.height
+ }));
+ }
+ function drawDepthLabels(root, context, {
+ asks,
+ bids,
+ maxVisibleCumulative,
+ rect,
+ inverted,
+ currentPriceY
+ }) {
+ const candidates = [
+ ...asks.map((level) => ({ ...level, side: "ask" })),
+ ...bids.map((level) => ({ ...level, side: "bid" }))
+ ].filter((level) => level.quantity / maxVisibleCumulative * rect.width >= DEPTH_LABEL_MIN_STEP).sort((left, right) => right.quantity - left.quantity || left.minPrice - right.minPrice);
+ if (!candidates.length) return;
+ const style = root.ownerDocument.defaultView.getComputedStyle(root);
+ const background = style.getPropertyValue("--jh-depth-label-background").trim();
+ const color = style.getPropertyValue("--jh-depth-label-color").trim();
+ const occupied = depthLabelObstacles(root, rect);
+ const counts = { ask: 0, bid: 0 };
+ const maxWidth = Math.min(DEPTH_LABEL_MAX_WIDTH, rect.width - 2);
+ context.save();
+ context.font = `11px ${style.fontFamily}`;
+ context.textAlign = "left";
+ context.textBaseline = "middle";
+ for (const level of candidates) {
+ if (counts[level.side] === DEPTH_LABELS_PER_SIDE) continue;
+ const quantity = DEPTH_QUANTITY_FORMAT.format(level.quantity);
+ const price = level.minPrice === level.maxPrice ? String(level.minPrice) : `${level.minPrice}–${level.maxPrice}`;
+ let text = `${price} · ${quantity}`;
+ let width = Math.ceil(context.measureText(text).width) + DEPTH_LABEL_PADDING * 2;
+ if (width > maxWidth) {
+ text = quantity;
+ width = Math.ceil(context.measureText(text).width) + DEPTH_LABEL_PADDING * 2;
+ }
+ if (width > maxWidth) continue;
+ const above = level.side === "ask" ? !inverted : inverted;
+ const y = above ? level.y - DEPTH_LABEL_HEIGHT - DEPTH_LABEL_GAP : level.y + DEPTH_LABEL_GAP;
+ if (y < 0 || y + DEPTH_LABEL_HEIGHT > rect.height) continue;
+ if (Number.isFinite(currentPriceY) && currentPriceY >= y - DEPTH_LABEL_GAP && currentPriceY <= y + DEPTH_LABEL_HEIGHT + DEPTH_LABEL_GAP) continue;
+ const edge = rect.width * (1 - level.cumulative / maxVisibleCumulative);
+ const x = Math.max(1, Math.min(edge + DEPTH_LABEL_GAP, rect.width - width - 1));
+ const box = { x, y, width, height: DEPTH_LABEL_HEIGHT };
+ if (occupied.some((other) => depthLabelBoxesOverlap(box, other))) continue;
+ context.fillStyle = background;
+ context.fillRect(x, y, width, DEPTH_LABEL_HEIGHT);
+ context.fillStyle = color;
+ context.fillText(text, x + DEPTH_LABEL_PADDING, y + DEPTH_LABEL_HEIGHT / 2);
+ occupied.push(box);
+ counts[level.side] += 1;
+ }
+ context.restore();
+ }
function renderDepthProfile(root, profile, geometry, currentPrice) {
setDepthProfileGeometry(root, geometry);
const canvas = root.querySelector(".jh-depth-profile-canvas");
@@ -4410,16 +4496,27 @@
drawSide(context, bids, maxVisibleCumulative, rect.width, "#0ecb81");
}
const currentPriceY = geometry.priceToCoordinate(currentPrice);
- if (!Number.isFinite(currentPriceY)) return true;
- context.save();
- context.strokeStyle = "rgba(240, 185, 11, .85)";
- context.lineWidth = 1;
- context.setLineDash([3, 3]);
- context.beginPath();
- context.moveTo(0, currentPriceY + 0.5);
- context.lineTo(rect.width, currentPriceY + 0.5);
- context.stroke();
- context.restore();
+ if (Number.isFinite(currentPriceY)) {
+ context.save();
+ context.strokeStyle = "rgba(240, 185, 11, .85)";
+ context.lineWidth = 1;
+ context.setLineDash([3, 3]);
+ context.beginPath();
+ context.moveTo(0, currentPriceY + 0.5);
+ context.lineTo(rect.width, currentPriceY + 0.5);
+ context.stroke();
+ context.restore();
+ }
+ if (maxVisibleCumulative > 0) {
+ drawDepthLabels(root, context, {
+ asks,
+ bids,
+ maxVisibleCumulative,
+ rect,
+ inverted: geometry.inverted,
+ currentPriceY
+ });
+ }
return true;
}
function clearDepthProfile(root) {
diff --git a/src/binance-orderbook-trade/dom/depth-profile.js b/src/binance-orderbook-trade/dom/depth-profile.js
index fb4fa17..18a6a36 100644
--- a/src/binance-orderbook-trade/dom/depth-profile.js
+++ b/src/binance-orderbook-trade/dom/depth-profile.js
@@ -5,6 +5,17 @@ const CHART_ROOT_SELECTOR = '.chart-widget-root';
const PRICE_AXIS_SELECTOR = '.chart-markup-table.price-axis-container';
const PRICE_COORDINATE_SEARCH_STEPS = 13;
const GEOMETRY_TOLERANCE_PX = 1;
+const DEPTH_LABEL_MAX_WIDTH = 88;
+const DEPTH_LABEL_HEIGHT = 16;
+const DEPTH_LABEL_PADDING = 3;
+const DEPTH_LABEL_GAP = 2;
+const DEPTH_LABEL_MIN_STEP = 8;
+const DEPTH_LABELS_PER_SIDE = 2;
+const DEPTH_QUANTITY_FORMAT = new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ maximumSignificantDigits: 3,
+ useGrouping: false,
+});
function hasVisibleBox(element) {
if (!element?.getClientRects().length) return false;
@@ -138,6 +149,8 @@ function installStyle(document) {
style.id = STYLE_ID;
style.textContent = `
#${DEPTH_PROFILE_ID} {
+ --jh-depth-label-background: var(--color-BasicBg, #fff);
+ --jh-depth-label-color: var(--color-SecondaryText, #474d57);
position: absolute;
z-index: 3;
top: 0;
@@ -282,10 +295,18 @@ function bucketVisibleLevels(levels, geometry) {
if (!Number.isFinite(coordinate)) continue;
const y = Math.max(0, Math.min(lastRow, Math.round(coordinate)));
const current = buckets.get(y);
- // Several exchange prices can map to one chart pixel. The outermost level has the
- // largest cumulative quantity and preserves the complete depth represented by that row.
- if (!current || level.cumulative > current.cumulative) {
- buckets.set(y, { ...level, y });
+ if (!current) {
+ buckets.set(y, { ...level, y, minPrice: level.price, maxPrice: level.price });
+ continue;
+ }
+ // The bar needs the outermost cumulative value, while its label needs every
+ // real quantity in this pixel row, including prices hidden by the outermost one.
+ current.quantity += level.quantity;
+ current.minPrice = Math.min(current.minPrice, level.price);
+ current.maxPrice = Math.max(current.maxPrice, level.price);
+ if (level.cumulative > current.cumulative) {
+ current.price = level.price;
+ current.cumulative = level.cumulative;
}
}
return [...buckets.values()].sort((left, right) => left.y - right.y);
@@ -308,6 +329,92 @@ function getMaxVisibleCumulative(...levelGroups) {
return maximum;
}
+function depthLabelBoxesOverlap(left, right) {
+ return left.x < right.x + right.width + DEPTH_LABEL_GAP
+ && left.x + left.width + DEPTH_LABEL_GAP > right.x
+ && left.y < right.y + right.height + DEPTH_LABEL_GAP
+ && left.y + left.height + DEPTH_LABEL_GAP > right.y;
+}
+
+function depthLabelObstacles(root, canvasRect) {
+ const controls = [root.querySelector('[data-depth-profile-toggle]')];
+ const status = root.querySelector('.jh-depth-profile-status');
+ if (status.textContent) controls.push(status);
+ return controls.map((element) => element.getBoundingClientRect())
+ .filter((rect) => rect.width > 0 && rect.height > 0)
+ .map((rect) => ({
+ x: rect.left - canvasRect.left,
+ y: rect.top - canvasRect.top,
+ width: rect.width,
+ height: rect.height,
+ }));
+}
+
+/**
+ * Labels describe visible price bands, not cumulative depth or changes over time.
+ * Keep their paint inside the existing canvas so hints never widen the overlay or
+ * create DOM mutations that schedule another chart-host synchronization.
+ */
+function drawDepthLabels(root, context, {
+ asks, bids, maxVisibleCumulative, rect, inverted, currentPriceY,
+}) {
+ const candidates = [
+ ...asks.map((level) => ({ ...level, side: 'ask' })),
+ ...bids.map((level) => ({ ...level, side: 'bid' })),
+ ].filter((level) => (
+ level.quantity / maxVisibleCumulative * rect.width >= DEPTH_LABEL_MIN_STEP
+ )).sort((left, right) => right.quantity - left.quantity || left.minPrice - right.minPrice);
+ if (!candidates.length) return;
+
+ const style = root.ownerDocument.defaultView.getComputedStyle(root);
+ const background = style.getPropertyValue('--jh-depth-label-background').trim();
+ const color = style.getPropertyValue('--jh-depth-label-color').trim();
+ const occupied = depthLabelObstacles(root, rect);
+ const counts = { ask: 0, bid: 0 };
+ const maxWidth = Math.min(DEPTH_LABEL_MAX_WIDTH, rect.width - 2);
+ context.save();
+ context.font = `11px ${style.fontFamily}`;
+ context.textAlign = 'left';
+ context.textBaseline = 'middle';
+
+ for (const level of candidates) {
+ if (counts[level.side] === DEPTH_LABELS_PER_SIDE) continue;
+ const quantity = DEPTH_QUANTITY_FORMAT.format(level.quantity);
+ const price = level.minPrice === level.maxPrice
+ ? String(level.minPrice)
+ : `${level.minPrice}–${level.maxPrice}`;
+ let text = `${price} · ${quantity}`;
+ let width = Math.ceil(context.measureText(text).width) + DEPTH_LABEL_PADDING * 2;
+ // A long price band uses the quantity-only layout; never truncate a price or
+ // assign an entire band's quantity to one representative price.
+ if (width > maxWidth) {
+ text = quantity;
+ width = Math.ceil(context.measureText(text).width) + DEPTH_LABEL_PADDING * 2;
+ }
+ if (width > maxWidth) continue;
+ const above = level.side === 'ask' ? !inverted : inverted;
+ const y = above
+ ? level.y - DEPTH_LABEL_HEIGHT - DEPTH_LABEL_GAP
+ : level.y + DEPTH_LABEL_GAP;
+ if (y < 0 || y + DEPTH_LABEL_HEIGHT > rect.height) continue;
+ if (Number.isFinite(currentPriceY)
+ && currentPriceY >= y - DEPTH_LABEL_GAP
+ && currentPriceY <= y + DEPTH_LABEL_HEIGHT + DEPTH_LABEL_GAP) continue;
+ const edge = rect.width * (1 - level.cumulative / maxVisibleCumulative);
+ const x = Math.max(1, Math.min(edge + DEPTH_LABEL_GAP, rect.width - width - 1));
+ const box = { x, y, width, height: DEPTH_LABEL_HEIGHT };
+ if (occupied.some((other) => depthLabelBoxesOverlap(box, other))) continue;
+
+ context.fillStyle = background;
+ context.fillRect(x, y, width, DEPTH_LABEL_HEIGHT);
+ context.fillStyle = color;
+ context.fillText(text, x + DEPTH_LABEL_PADDING, y + DEPTH_LABEL_HEIGHT / 2);
+ occupied.push(box);
+ counts[level.side] += 1;
+ }
+ context.restore();
+}
+
export function renderDepthProfile(root, profile, geometry, currentPrice) {
setDepthProfileGeometry(root, geometry);
const canvas = root.querySelector('.jh-depth-profile-canvas');
@@ -332,16 +439,22 @@ export function renderDepthProfile(root, profile, geometry, currentPrice) {
}
const currentPriceY = geometry.priceToCoordinate(currentPrice);
- if (!Number.isFinite(currentPriceY)) return true;
- context.save();
- context.strokeStyle = 'rgba(240, 185, 11, .85)';
- context.lineWidth = 1;
- context.setLineDash([3, 3]);
- context.beginPath();
- context.moveTo(0, currentPriceY + 0.5);
- context.lineTo(rect.width, currentPriceY + 0.5);
- context.stroke();
- context.restore();
+ if (Number.isFinite(currentPriceY)) {
+ context.save();
+ context.strokeStyle = 'rgba(240, 185, 11, .85)';
+ context.lineWidth = 1;
+ context.setLineDash([3, 3]);
+ context.beginPath();
+ context.moveTo(0, currentPriceY + 0.5);
+ context.lineTo(rect.width, currentPriceY + 0.5);
+ context.stroke();
+ context.restore();
+ }
+ if (maxVisibleCumulative > 0) {
+ drawDepthLabels(root, context, {
+ asks, bids, maxVisibleCumulative, rect, inverted: geometry.inverted, currentPriceY,
+ });
+ }
return true;
}
diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js
index 1e8863a..accc9e9 100644
--- a/src/binance-orderbook-trade/index.user.js
+++ b/src/binance-orderbook-trade/index.user.js
@@ -3,7 +3,7 @@
// @namespace binance.orderbook.trade
// @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
// @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E
-// @version 2.7.206
+// @version 2.7.207
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
diff --git a/test/dom/binance-orderbook-trade/depth-profile.test.js b/test/dom/binance-orderbook-trade/depth-profile.test.js
index cfb3469..5c1cf7f 100644
--- a/test/dom/binance-orderbook-trade/depth-profile.test.js
+++ b/test/dom/binance-orderbook-trade/depth-profile.test.js
@@ -347,10 +347,14 @@ test('draws bid and ask bars plus the latest-trade divider', () => {
const root = ensureDepthProfileView(document, host, { onToggle: () => {} });
const calls = [];
const fillStyles = [];
- root.querySelector('canvas').getContext = () => ({
+ const canvas = root.querySelector('canvas');
+ canvas.getBoundingClientRect = () => ({ width: 132, height: 240, left: 0, top: 0 });
+ canvas.getContext = () => ({
beginPath: () => calls.push('beginPath'),
clearRect: () => calls.push('clearRect'),
fillRect: (...args) => calls.push(['fillRect', ...args]),
+ fillText: () => {},
+ measureText: (text) => ({ width: text.length * 6 }),
lineTo: () => calls.push('lineTo'),
moveTo: (...args) => calls.push(['moveTo', ...args]),
restore: () => calls.push('restore'),
@@ -364,6 +368,7 @@ test('draws bid and ask bars plus the latest-trade divider', () => {
const geometry = {
top: 0,
height: 240,
+ inverted: false,
priceToCoordinate: (price) => ({ 100: 180, 100.5: 140, 101: 80 }[price] ?? null),
};
assert.equal(renderDepthProfile(root, {
@@ -371,17 +376,18 @@ test('draws bid and ask bars plus the latest-trade divider', () => {
maxPrice: 102,
midPrice: 99.5,
maxCumulative: 5,
- bids: [{ price: 100, cumulative: 5 }],
- asks: [{ price: 101, cumulative: 4 }, { price: 105, cumulative: 5 }],
+ bids: [{ price: 100, quantity: 5, cumulative: 5 }],
+ asks: [{ price: 101, quantity: 4, cumulative: 4 }, { price: 105, quantity: 1, cumulative: 5 }],
}, geometry, 100.5), true);
assert.equal(root.style.height, '240px');
- assert.equal(calls.filter((call) => Array.isArray(call) && call[0] === 'fillRect').length, 2);
- assert.deepEqual(fillStyles, [
+ const bars = calls.filter((call) => Array.isArray(call) && call[0] === 'fillRect' && call[4] === 1);
+ assert.equal(bars.length, 2);
+ assert.deepEqual(fillStyles.slice(0, 2), [
'#f6465d',
'#0ecb81',
]);
assert.deepEqual(
- calls.filter((call) => Array.isArray(call) && call[0] === 'fillRect').map((call) => call[2]),
+ bars.map((call) => call[2]),
[80, 180],
);
assert.deepEqual(calls.find((call) => Array.isArray(call) && call[0] === 'moveTo'), ['moveTo', 0, 140.5]);
@@ -399,10 +405,13 @@ test('draws one bar per visible CSS pixel row and scales width to visible depth'
const root = ensureDepthProfileView(document, host, { onToggle: () => {} });
const fillRects = [];
const canvas = root.querySelector('canvas');
+ canvas.getBoundingClientRect = () => ({ width: 132, height: 240, left: 0, top: 0 });
canvas.getContext = () => ({
beginPath: () => {},
clearRect: () => {},
- fillRect: (...args) => fillRects.push(args),
+ fillRect: (...args) => { if (args[3] === 1) fillRects.push(args); },
+ fillText: () => {},
+ measureText: (text) => ({ width: text.length * 6 }),
lineTo: () => {},
moveTo: () => {},
restore: () => {},
@@ -415,6 +424,7 @@ test('draws one bar per visible CSS pixel row and scales width to visible depth'
const geometry = {
top: 0,
height: 240,
+ inverted: false,
priceToCoordinate: (price) => ({
99: 180.4,
101: 80.2,
@@ -423,11 +433,11 @@ test('draws one bar per visible CSS pixel row and scales width to visible depth'
};
renderDepthProfile(root, {
maxCumulative: 1_000,
- bids: [{ price: 99, cumulative: 4 }],
+ bids: [{ price: 99, quantity: 4, cumulative: 4 }],
asks: [
- { price: 101, cumulative: 3 },
- { price: 102, cumulative: 8 },
- { price: 110, cumulative: 1_000 },
+ { price: 101, quantity: 3, cumulative: 3 },
+ { price: 102, quantity: 5, cumulative: 8 },
+ { price: 110, quantity: 992, cumulative: 1_000 },
],
}, geometry, 100);
@@ -438,6 +448,155 @@ test('draws one bar per visible CSS pixel row and scales width to visible depth'
assert.equal(fillRects[1][2], canvas.getBoundingClientRect().width / 2);
});
+function createLabelRenderer({ width = 132, height = 240, inverted = false, coordinates } = {}) {
+ const dom = createChartDom();
+ const { document } = dom.window;
+ const { host } = findDepthProfileHost(document);
+ const root = ensureDepthProfileView(document, host, { onToggle: () => {} });
+ const canvas = root.querySelector('canvas');
+ canvas.getBoundingClientRect = () => ({ width, height, left: 0, top: 0 });
+ root.querySelector('[data-depth-profile-toggle]').getBoundingClientRect = () => ({
+ left: width - 28, top: 8, width: 24, height: 24,
+ });
+ const rectangles = [];
+ const labels = [];
+ const context = {
+ beginPath() {},
+ clearRect() { rectangles.length = 0; labels.length = 0; },
+ fillRect(x, y, boxWidth, boxHeight) {
+ rectangles.push({ x, y, width: boxWidth, height: boxHeight, color: this.fillStyle });
+ },
+ fillText(text, x, y) { labels.push({ text, x, y, font: this.font }); },
+ measureText: (text) => ({ width: text.length * 6 }),
+ lineTo() {},
+ moveTo() {},
+ restore() {},
+ save() {},
+ setLineDash() {},
+ setTransform() {},
+ stroke() {},
+ };
+ canvas.getContext = () => context;
+ const geometry = {
+ top: 0,
+ height,
+ rightInset: 88,
+ inverted,
+ priceToCoordinate: (price) => coordinates[price] ?? null,
+ };
+ return {
+ root,
+ labels,
+ rectangles,
+ render: (profile, currentPrice = null) => renderDepthProfile(root, profile, geometry, currentPrice),
+ };
+}
+
+function depthLevels(entries) {
+ let cumulative = 0;
+ return entries.map(([price, quantity]) => {
+ cumulative += quantity;
+ return { price, quantity, cumulative };
+ });
+}
+
+test('labels the complete visible pixel-row quantity, not its last level or offscreen cumulative depth', () => {
+ const view = createLabelRenderer({ coordinates: { 99: 180.4, 101: 80.2, 102: 80.4 } });
+ view.render({
+ maxCumulative: 1_000,
+ bids: depthLevels([[99, 4]]),
+ asks: depthLevels([[101, 3], [102, 5], [110, 992]]),
+ });
+
+ assert.deepEqual(view.labels.map(({ text }) => text), ['101–102 · 8', '99 · 4']);
+ assert.deepEqual(view.rectangles.filter(({ height }) => height === 1), [
+ { x: 0, y: 80, width: 132, height: 1, color: '#f6465d' },
+ { x: 66, y: 180, width: 66, height: 1, color: '#0ecb81' },
+ ]);
+});
+
+test('chooses at most two large quantity steps per side instead of the largest cumulative bars', () => {
+ const view = createLabelRenderer({ coordinates: { 99: 220, 101: 180, 102: 160, 103: 140, 104: 120, 105: 100, 106: 80 } });
+ view.render({
+ bids: depthLevels([[99, 1]]),
+ asks: depthLevels([[101, 300], [102, 5], [103, 200], [104, 4], [105, 100], [106, 3]]),
+ });
+
+ assert.deepEqual(view.labels.map(({ text }) => text), ['101 · 300', '103 · 200']);
+});
+
+test('uses quantity-only text when a complete price band is too wide and keeps every label inside the canvas', () => {
+ const view = createLabelRenderer({ coordinates: { 0.00010001: 80.2, 0.00010002: 80.4, 0.00009: 180 } });
+ view.render({
+ bids: depthLevels([[0.00009, 620_000]]),
+ asks: depthLevels([[0.00010001, 2_400_000], [0.00010002, 1_400_000]]),
+ });
+
+ assert.deepEqual(view.labels.map(({ text }) => text), ['3.8M', '620K']);
+ for (const box of view.rectangles.filter(({ height }) => height === 16)) {
+ assert.ok(box.width <= 88);
+ assert.ok(box.x >= 0 && box.x + box.width <= 132);
+ assert.ok(box.y >= 0 && box.y + box.height <= 240);
+ }
+ assert.deepEqual(view.rectangles.filter(({ height }) => height === 16).map(({ width }) => width), [30, 30]);
+});
+
+test('formats quantities without trailing zeroes, unit-boundary errors, or rounding small nonzero amounts to zero', () => {
+ const view = createLabelRenderer({ coordinates: { 101: 80 } });
+ for (const [quantity, expected] of [[2_400_000, '101 · 2.4M'], [999_950, '101 · 1M'], [0.00002, '101 · 0.00002']]) {
+ view.render({ bids: [], asks: depthLevels([[101, quantity]]) });
+ assert.deepEqual(view.labels.map(({ text }) => text), [expected]);
+ }
+});
+
+test('keeps nearby labels apart and does not cover the current-price divider', () => {
+ const view = createLabelRenderer({ coordinates: { 99: 150, 100: 110, 101: 125, 102: 95, 103: 90 } });
+ view.render({
+ bids: depthLevels([[99, 80]]),
+ asks: depthLevels([[101, 70], [102, 100], [103, 90]]),
+ }, 100);
+
+ assert.deepEqual(view.labels.map(({ text }) => text), ['102 · 100', '99 · 80']);
+ const boxes = view.rectangles.filter(({ height }) => height === 16);
+ assert.ok(boxes.every((box) => box.y + box.height < 110 || box.y > 110));
+ assert.ok(boxes[0].y + boxes[0].height < boxes[1].y);
+});
+
+test('places bid and ask labels on opposite sides of a shared pixel row, including inverted scales', () => {
+ for (const inverted of [false, true]) {
+ const coordinates = inverted ? { 99: 100.2, 101: 100.4 } : { 99: 100.4, 101: 100.2 };
+ const view = createLabelRenderer({ inverted, coordinates });
+ view.render({ bids: depthLevels([[99, 9]]), asks: depthLevels([[101, 10]]) });
+
+ assert.deepEqual(view.labels.map(({ text }) => text), ['101 · 10', '99 · 9']);
+ assert.deepEqual(view.rectangles.filter(({ height }) => height === 16).map(({ y }) => y), inverted ? [102, 82] : [82, 102]);
+ }
+});
+
+test('omits labels that would overlap the collapse button or extend past the vertical canvas boundary', () => {
+ const view = createLabelRenderer({ coordinates: { 99: 220, 101: 38, 102: 10 } });
+ view.render({
+ bids: depthLevels([[99, 1_000]]),
+ asks: depthLevels([[101, 200], [102, 100]]),
+ });
+ assert.deepEqual(view.labels.map(({ text }) => text), ['99 · 1K']);
+
+ const narrow = createLabelRenderer({ width: 20, coordinates: { 101: 80 } });
+ narrow.render({ bids: [], asks: depthLevels([[101, 620_000]]) });
+ assert.deepEqual(narrow.labels, []);
+});
+
+test('repaints changed quantities and removes old labels when the depth canvas clears', () => {
+ const view = createLabelRenderer({ coordinates: { 101: 80 } });
+ view.render({ bids: [], asks: depthLevels([[101, 3_800_000]]) });
+ assert.deepEqual(view.labels.map(({ text }) => text), ['101 · 3.8M']);
+ view.render({ bids: [], asks: depthLevels([[101, 1_500_000]]) });
+ assert.deepEqual(view.labels.map(({ text }) => text), ['101 · 1.5M']);
+ clearDepthProfile(view.root);
+ assert.deepEqual(view.labels, []);
+ assert.deepEqual(view.rectangles, []);
+});
+
test('updates root geometry without rewriting unchanged styles', () => {
const dom = createChartDom();
const { document } = dom.window;