Skip to main content

Chart Visualization Guide

This guide covers rendering visual markers, icons, and custom graphics on Bookmap charts using the Layer1 API.

Overview​

Bookmap provides two primary approaches for chart visualization:

ApproachClassUse CaseComplexity
Simple Icon MarkersOnlineCalculatable.MarkerFixed-size icons at specific price levelsLow
Data-Bound GraphicsOnlineCalculatable.DataCoordinateMarkerDynamic graphics spanning price ranges (e.g., candles)Medium

When to Use Each Approach​

Use Marker (Simple Icons) when:

  • Displaying trade executions
  • Showing order placements/cancellations
  • Marking specific price levels
  • Icon size is independent of chart zoom

Use DataCoordinateMarker when:

  • Drawing OHLC candles or bars
  • Creating graphics that span price ranges
  • Image height depends on price difference (high - low)
  • Graphics need to scale with chart zoom

OnlineCalculatable.Marker - Simple Icon Markers​

The Marker class represents a fixed-size icon placed at a specific price level on the chart.

Constructor​

public Marker(double markerY, int iconOffsetX, int iconOffsetY, BufferedImage icon)

Parameters​

ParameterTypeDescription
markerYdoubleVertical position (price / pips for PRIMARY chart, raw price for BOTTOM)
iconOffsetXintHorizontal pixel offset from marker position
iconOffsetYintVertical pixel offset from marker position
iconBufferedImageThe image to display

Coordinate System​

                    iconOffsetX
Γ’β€”β€žΓ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€“ΒΊ

Ò”ŒÒ”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò” Ò–²
Γ’β€β€š Γ’β€β€š Γ’β€β€š
Γ’β€β€š ICON Γ’β€β€š Γ’β€β€š iconOffsetY
Γ’β€β€š Γ’β€β€š Γ’β€β€š
Γ’β€β€Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€Λœ Ò–¼
Ò—
(markerY position)
  • markerY: The price level where the marker anchors (in price/pips units for PRIMARY, raw price for BOTTOM)
  • iconOffsetX: Pixels to shift the icon horizontally (negative = left, positive = right)
  • iconOffsetY: Pixels to shift the icon vertically (negative = up, positive = down)

Icon Positioning Strategies​

Centered on Price Level​

new Marker(price, -icon.getWidth() / 2, -icon.getHeight() / 2, icon)

Anchored Above Price Level​

new Marker(price, -icon.getWidth() / 2, -icon.getHeight(), icon)

Anchored Below Price Level​

new Marker(price, -icon.getWidth() / 2, 0, icon)

Anchored to Left of Price​

new Marker(price, -icon.getWidth(), -icon.getHeight() / 2, icon)

Trade Marker Example​

From Layer1ApiMarkersDemo.java:

// Create a 16x16 "X" marker icon
BufferedImage tradeIcon = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
Graphics graphics = tradeIcon.getGraphics();
graphics.setColor(Color.BLUE);
graphics.drawLine(0, 0, 15, 15);
graphics.drawLine(15, 0, 0, 15);

// Use the marker - centered on the price
@Override
public void onTrade(String alias, double price, int size, TradeInfo tradeInfo) {
if (alias.equals(indicatorAlias)) {
listener.accept(new Marker(
price, // markerY (already in price/pips for real-time)
-tradeIcon.getWidth() / 2, // center horizontally
-tradeIcon.getHeight() / 2, // center vertically
tradeIcon
));
}
}

DataCoordinateMarker - Data-Bound Graphics​

The DataCoordinateMarker interface enables creating graphics that span price ranges, such as OHLC candles. The marker dynamically generates its image based on the current chart scale.

Interface Methods​

public interface DataCoordinateMarker {
double getMinY(); // Lower edge (for range adjustment)
double getMaxY(); // Upper edge (for range adjustment)
double getValueY(); // Value for widget/line display
Marker makeMarker(Function<Double, Integer> yDataCoordinateToPixelFunction);
}

Method Descriptions​

MethodPurpose
getMinY()Returns Y coordinate of lower edge (used for bottom panel range adjustment)
getMaxY()Returns Y coordinate of upper edge (used for bottom panel range adjustment)
getValueY()Returns value used for widget display and line drawing
makeMarker()Creates the visual Marker using the provided coordinate conversion function

Pixel Coordinate Conversion​

The yDataCoordinateToPixelFunction converts price values to pixel coordinates:

Function<Double, Integer> yDataCoordinateToPixelFunction

Important Warning:

The function may return extremely large or negative values for prices outside the visible chart range. Always validate and clamp pixel values to prevent memory issues or rendering artifacts (e.g., 10,000 pixels max).

Complete BarEvent Implementation​

From Layer1ApiBarsDemo.java:

private static class BarEvent implements CustomGeneratedEvent, DataCoordinateMarker {
private static final long serialVersionUID = 1L;

private long time;
double open, low, high, close;
transient int bodyWidthPx; // transient: not serialized

public BarEvent(long time) {
this(time, Double.NaN);
}

public BarEvent(long time, double open) {
this(time, open, open, open, open, -1);
}

public BarEvent(long time, double open, double low, double high, double close, int bodyWidthPx) {
this.time = time;
this.open = open;
this.low = low;
this.high = high;
this.close = close;
this.bodyWidthPx = bodyWidthPx;
}

// Copy constructor (important for cloning)
public BarEvent(BarEvent other) {
this(other.time, other.open, other.low, other.high, other.close, other.bodyWidthPx);
}

@Override
public long getTime() { return time; }

@Override
public Object clone() {
return new BarEvent(time, open, low, high, close, bodyWidthPx);
}

@Override
public double getMinY() { return low; }

@Override
public double getMaxY() { return high; }

@Override
public double getValueY() { return close; }

public void update(double price) {
if (Double.isNaN(price)) return;

if (Double.isNaN(open)) {
open = low = high = price;
} else {
low = Math.min(low, price);
high = Math.max(high, price);
}
close = price;
}

@Override
public Marker makeMarker(Function<Double, Integer> yDataCoordinateToPixelFunction) {
// Convert OHLC prices to pixel coordinates
int top = yDataCoordinateToPixelFunction.apply(high);
int bottom = yDataCoordinateToPixelFunction.apply(low);
int openPx = yDataCoordinateToPixelFunction.apply(open);
int closePx = yDataCoordinateToPixelFunction.apply(close);

// Calculate body bounds
int bodyLow = Math.min(openPx, closePx);
int bodyHigh = Math.max(openPx, closePx);

// Create image sized to contain the full candle
int imageHeight = top - bottom + 1;
BufferedImage bufferedImage = new BufferedImage(bodyWidthPx, imageHeight, BufferedImage.TYPE_INT_ARGB);
int imageCenterX = bufferedImage.getWidth() / 2;

Graphics2D graphics = bufferedImage.createGraphics();

// Clear background (transparent)
graphics.setBackground(new Color(0, 0, 0, 0));
graphics.clearRect(0, 0, bufferedImage.getWidth(), bufferedImage.getHeight());

// Draw wick (shadow)
graphics.setColor(Color.WHITE);
graphics.drawLine(imageCenterX, 0, imageCenterX, imageHeight);

// Draw body (green for bullish, red for bearish)
// Note: BufferedImage Y-axis points downward
graphics.setColor(open < close ? Color.GREEN : Color.RED);
graphics.fillRect(0, top - bodyHigh, bodyWidthPx, bodyHigh - bodyLow + 1);

graphics.dispose();

// Calculate offsets to position the image correctly
// Reference point is 'close', offset so close aligns with markerY
int iconOffsetY = bottom - closePx;
int iconOffsetX = -imageCenterX; // Center horizontally

return new Marker(close, iconOffsetX, iconOffsetY, bufferedImage);
}

/**
* Convert from level numbers to display prices for bottom panel
*/
public void applyPips(double pips) {
open *= pips;
low *= pips;
high *= pips;
close *= pips;
}
}

Candle Coordinate Diagram​

    Ò”ŒÒ”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”
Γ’β€β€š BufferedImage Coordinate Space Γ’β€β€š
Γ’β€β€š Γ’β€β€š
Γ’β€β€š top (high) Ò”€Ò”€Ò”€Ò”€Ò”€Ò”¬Ò”€Ò”€Ò”€Ò”€Ò”€ Y=0 Γ’β€β€š
Γ’β€β€š Γ’β€β€š Γ’β€β€š
Γ’β€β€š bodyHigh Ò”€Ò”€Ò”¬Ò”€Ò”€Ò”€Ò”€Ò”´Ò”€Ò”€Ò”€Ò”€Ò”¬ Γ’β€β€š
Γ’β€β€š Γ’β€β€š BODY Γ’β€β€š Γ’β€β€š
Γ’β€β€š bodyLow Ò”€Ò”€Ò”€Ò”´Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”´ Γ’β€β€š
Γ’β€β€š Γ’β€β€š Γ’β€β€š
Γ’β€β€š bottom (low) Ò”€Ò”€Ò”€Ò”´Ò”€Ò”€Ò”€Ò”€Ò”€ Y=imageHeight Γ’β€β€š
Γ’β€β€Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€Λœ

Chart Space (Y increases upward, pixel Y increases downward)

Dynamic Icon Generation​

Icons can be generated dynamically based on user settings or runtime conditions.

Pattern: Reload Icon on Color Change​

From Layer1ApiMarkersDemo.java:

private Map<String, BufferedImage> orderIcons = Collections.synchronizedMap(new HashMap<>());

private void reloadOrderIcon(String alias) {
BufferedImage orderIcon = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
Graphics graphics = orderIcon.getGraphics();

// Clear with transparent background
graphics.setColor(Colors.TRANSPARENT);
graphics.fillRect(0, 0, 15, 15);

// Draw circle with user-selected color
graphics.setColor(getColor(alias, INDICATOR_CIRCLES_COLOR_NAME));
graphics.drawOval(0, 0, 15, 15);

// Cache the icon per alias
orderIcons.put(alias, orderIcon);
}

// Call when color settings change
@Override
public void onColorsChanged() {
reloadOrderIcon(alias);

InvalidateInterface invalidateInterface = invalidateInterfaceMap.get(INDICATOR_NAME_CIRCLES);
if (invalidateInterface != null) {
invalidateInterface.invalidate(); // Trigger redraw
}
}

Icon Caching Best Practices​

  1. Cache per alias: Different instruments may have different settings
  2. Use synchronized map: Icons may be accessed from multiple threads
  3. Invalidate on change: Call InvalidateInterface.invalidate() to trigger redraw
  4. Reasonable size: Keep icons small (16x16 to 32x32 typical)

Price Unit Conversion​

Understanding pips​

The pips value from InstrumentInfo converts between:

  • Level numbers (integer price levels used internally)
  • Display prices (actual dollar/cent values shown to users)
// Store pips per instrument
private Map<String, Double> pipsMap = new ConcurrentHashMap<>();

@Override
public void onInstrumentAdded(String alias, InstrumentInfo instrumentInfo) {
pipsMap.put(alias, instrumentInfo.pips);
}

Conversion Rules​

ContextFormulaExample
Level Ò†’ Display pricelevel * pips4000 * 0.25 = 1000.00
Display price Ò†’ Levelprice / pips1000.00 / 0.25 = 4000

GraphType and Price Units​

GraphTypemarkerY UnitsNotes
PRIMARYLevel number (price / pips)Main heatmap overlay
BOTTOMDisplay price (raw value)Bottom indicator panel

Pattern: applyPips for Bottom Panel​

public void applyPips(double pips) {
open *= pips;
low *= pips;
high *= pips;
close *= pips;
}

// Usage in calculateValuesInRange
@Override
public void calculateValuesInRange(String indicatorName, String indicatorAlias,
long t0, long intervalWidth, int intervalsNumber, CalculatedResultListener listener) {

String userName = indicatorsFullNameToUserName.get(indicatorName);
boolean isBottomChart = userName.equals(INDICATOR_NAME_BARS_BOTTOM);
Double pips = pipsMap.get(indicatorAlias);

// ... fetch data ...

for (int i = 1; i <= intervalsNumber; i++) {
BarEvent value = getBarEvent(result.get(i));
if (value != null) {
value = new BarEvent(value); // Clone before modifying!
value.setBodyWidthPx(bodyWidth);

if (isBottomChart) {
value.applyPips(pips); // Convert to display prices
}

listener.provideResponse(value);
} else {
listener.provideResponse(Double.NaN);
}
}

listener.setCompleted();
}

Multi-Instrument Support​

Always use a map to handle multiple instruments:

// Historical data retrieval
Double pips = pipsMap.getOrDefault(alias, 1.0);
double displayPrice = executionInfo.price / pips;

// Real-time callbacks
@Override
public void onOrderExecuted(ExecutionInfo executionInfo) {
String alias = orderIdToAlias.get(executionInfo.orderId);
if (alias != null && alias.equals(indicatorAlias)) {
Double pips = pipsMap.get(alias);
if (pips != null) {
listener.accept(new Marker(
executionInfo.price / pips, // Convert to level number
-orderIcon.getHeight() / 2,
-orderIcon.getWidth() / 2,
orderIcon
));
}
}
}

GraphType and Rendering Location​

GraphType.PRIMARY​

Renders on the main heatmap chart overlay.

Layer1ApiUserMessageModifyIndicator.builder(MyStrategy.class, "Main Chart Indicator")
.setIsAdd(true)
.setGraphType(GraphType.PRIMARY)
.setOnlineCalculatable(this)
.build();

Characteristics:

  • Overlays the depth/trade heatmap
  • Uses level numbers (price / pips) for Y coordinates
  • Good for trade markers, price levels, overlaid candles

GraphType.BOTTOM​

Renders in the bottom indicator panel.

Layer1ApiUserMessageModifyIndicator.builder(MyStrategy.class, "Bottom Panel Indicator")
.setIsAdd(true)
.setGraphType(GraphType.BOTTOM)
.setOnlineCalculatable(this)
.build();

Characteristics:

  • Displays below the main chart
  • Uses raw price values for Y coordinates
  • Has its own Y-axis scale
  • Good for oscillators, volume, custom metrics

GraphType.NONE​

No visual indicator graph (widget only).

Layer1ApiUserMessageModifyIndicator.builder(MyStrategy.class, "Widget Only")
.setIsAdd(true)
.setGraphType(GraphType.NONE)
.setIsSupportWidget(true)
.build();

Performance Considerations​

Icon Caching​

// BAD: Creating new icon for every trade
@Override
public void onTrade(String alias, double price, int size, TradeInfo tradeInfo) {
BufferedImage icon = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
// ... draw icon ...
listener.accept(new Marker(price, 0, 0, icon)); // Memory leak!
}

// GOOD: Reuse cached icons
private BufferedImage tradeIcon; // Created once in constructor

@Override
public void onTrade(String alias, double price, int size, TradeInfo tradeInfo) {
listener.accept(new Marker(price, -8, -8, tradeIcon)); // Reuses icon
}

Dynamic Width Based on Zoom​

For DataCoordinateMarker, adjust body width based on interval width:

private static final int MAX_BODY_WIDTH = 30;
private static final int MIN_BODY_WIDTH = 1;
private static final long CANDLE_INTERVAL_NS = TimeUnit.SECONDS.toNanos(30);

private int getBodyWidth(long intervalWidth) {
long bodyWidth = CANDLE_INTERVAL_NS / intervalWidth;
bodyWidth = Math.max(bodyWidth, MIN_BODY_WIDTH);
bodyWidth = Math.min(bodyWidth, MAX_BODY_WIDTH);
return (int) bodyWidth;
}

@Override
public void onIntervalWidth(long intervalWidth) {
this.bodyWidth = getBodyWidth(intervalWidth);
}

Avoiding Excessive Allocations​

From the Layer1ApiBarsDemo.java comment:

Note that caching and reusing markers would improve efficiency, but for simplicity we won't do that here. If you do decide to cache the icons - be mindful of the cache size.

Consider:

  • LRU cache for dynamically generated icons
  • Object pooling for frequently created markers
  • Limiting marker count per visible range

Complete Example: Trade Execution Markers​

@Layer1Attachable
@Layer1StrategyName("Execution Marker Demo")
@Layer1ApiVersion(Layer1ApiVersionValue.VERSION2)
public class ExecutionMarkerDemo implements
Layer1ApiFinishable,
Layer1ApiAdminAdapter,
Layer1ApiInstrumentListener,
OnlineCalculatable {

private static final String INDICATOR_NAME = "Executions";

private Layer1ApiProvider provider;
private DataStructureInterface dataStructureInterface;
private Map<String, Double> pipsMap = new ConcurrentHashMap<>();
private Map<String, String> indicatorsFullNameToUserName = new HashMap<>();

// Pre-created icons
private BufferedImage buyIcon;
private BufferedImage sellIcon;

public ExecutionMarkerDemo(Layer1ApiProvider provider) {
this.provider = provider;
ListenableHelper.addListeners(provider, this);

// Create buy icon (green up arrow)
buyIcon = createArrowIcon(Color.GREEN, true);

// Create sell icon (red down arrow)
sellIcon = createArrowIcon(Color.RED, false);
}

private BufferedImage createArrowIcon(Color color, boolean pointUp) {
BufferedImage icon = new BufferedImage(12, 12, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = icon.createGraphics();
g.setColor(color);

int[] xPoints = {6, 0, 12};
int[] yPoints = pointUp ? new int[]{0, 12, 12} : new int[]{12, 0, 0};
g.fillPolygon(xPoints, yPoints, 3);

g.dispose();
return icon;
}

@Override
public void onUserMessage(Object data) {
if (data instanceof UserMessageLayersChainCreatedTargeted) {
UserMessageLayersChainCreatedTargeted message = (UserMessageLayersChainCreatedTargeted) data;
if (message.targetClass == getClass()) {
// Request DataStructureInterface
provider.sendUserMessage(new Layer1ApiDataInterfaceRequestMessage(
dsi -> this.dataStructureInterface = dsi
));

// Register indicator
Layer1ApiUserMessageModifyIndicator msg = Layer1ApiUserMessageModifyIndicator
.builder(ExecutionMarkerDemo.class, INDICATOR_NAME)
.setIsAdd(true)
.setGraphType(GraphType.PRIMARY)
.setOnlineCalculatable(this)
.setIndicatorLineStyle(IndicatorLineStyle.NONE)
.build();

indicatorsFullNameToUserName.put(msg.fullName, msg.userName);
provider.sendUserMessage(msg);
}
}
}

@Override
public void onInstrumentAdded(String alias, InstrumentInfo instrumentInfo) {
pipsMap.put(alias, instrumentInfo.pips);
}

@Override
public void calculateValuesInRange(String indicatorName, String indicatorAlias,
long t0, long intervalWidth, int intervalsNumber, CalculatedResultListener listener) {

if (dataStructureInterface == null) {
listener.setCompleted();
return;
}

// Get order events for the range
ArrayList<TreeResponseInterval> intervals = dataStructureInterface.get(
t0, intervalWidth, intervalsNumber, indicatorAlias,
new StandardEvents[] { StandardEvents.ORDER }
);

Double pips = pipsMap.getOrDefault(indicatorAlias, 1.0);

for (int i = 1; i <= intervalsNumber; i++) {
OrderUpdatesExecutionsAggregationEvent orders =
(OrderUpdatesExecutionsAggregationEvent) intervals.get(i).events
.get(StandardEvents.ORDER.toString());

ArrayList<Marker> markers = new ArrayList<>();

for (Object obj : orders.orderUpdates) {
if (obj instanceof OrderExecutedEvent) {
OrderExecutedEvent exec = (OrderExecutedEvent) obj;
double priceLevel = exec.executionInfo.price / pips;

// Determine buy/sell from order info (simplified)
BufferedImage icon = exec.executionInfo.size > 0 ? buyIcon : sellIcon;

markers.add(new Marker(
priceLevel,
-icon.getWidth() / 2,
-icon.getHeight() / 2,
icon
));
}
}

listener.provideResponse(markers.isEmpty() ? Double.NaN : markers);
}

listener.setCompleted();
}

@Override
public OnlineValueCalculatorAdapter createOnlineValueCalculator(
String indicatorName, String indicatorAlias, long time,
Consumer<Object> listener, InvalidateInterface invalidateInterface) {

return new OnlineValueCalculatorAdapter() {
private Map<String, Boolean> orderIdToBuy = new HashMap<>();

@Override
public void onOrderUpdated(OrderInfoUpdate update) {
orderIdToBuy.put(update.orderId, update.isBuy);
}

@Override
public void onOrderExecuted(ExecutionInfo exec) {
Boolean isBuy = orderIdToBuy.get(exec.orderId);
if (isBuy == null) return;

Double pips = pipsMap.get(indicatorAlias);
if (pips == null) return;

BufferedImage icon = isBuy ? buyIcon : sellIcon;

listener.accept(new Marker(
exec.price / pips,
-icon.getWidth() / 2,
-icon.getHeight() / 2,
icon
));
}
};
}

@Override
public void finish() {
provider.sendUserMessage(new Layer1ApiUserMessageModifyIndicator(
ExecutionMarkerDemo.class, INDICATOR_NAME, false
));
}

// Required interface methods
@Override public void onInstrumentRemoved(String alias) {}
@Override public void onInstrumentNotFound(String symbol, String exchange, String type) {}
@Override public void onInstrumentAlreadySubscribed(String symbol, String exchange, String type) {}
}

Cross-References​