SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

charts: rendu aligné sur les pixels physiques (hairlines 1 px device pour grille/mèches/crosshair/axes, corps impairs snappés, contour 1 px, texte d'axe sur pixels entiers, pilules 2 px), mode --sharp du script de captures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent 6525eb7

11 changed files +244 −127

modified hfmarketdata/web/scripts/charts-shots.mjs +44 −0
@@ -51,6 +51,45 @@ async function waitFor(url, ms = 30_000) {
51 51 throw new Error(`Vite did not start on ${url}`)
52 52 }
53 53
54 +/**
55 + * Sharpness proof: the same chart (fixed seed, fixed view) captured at a given DPR, plus a 300×200 CSS px crop
56 + * magnified ×3 with nearest-neighbour sampling so individual device pixels are visible.
57 + * Writes /tmp/hfmd-charts/sharp-<tag>.png and sharp-<tag>-zoom.png (and -dpr1 / -dpr3 variants with --dprs).
58 + */
59 +async function sharpShots(browser, tag, dprs) {
60 + for (const dpr of dprs) {
61 + const suffix = dpr === 2 ? '' : `-dpr${dpr}`
62 + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: dpr })
63 + const page = await ctx.newPage()
64 + const errors = []
65 + page.on('pageerror', e => errors.push(String(e)))
66 + await page.goto(`${HARNESS}?tf=1min&n=50000&ind=sma,ema,rsi&reduced=1&hidecontrols=1`, { waitUntil: 'networkidle' })
67 + await page.waitForFunction(() => window.__chart && window.__chart.getData().length > 0)
68 + await page.evaluate(() => { const c = window.__chart; c.setVisibleRange({ fromIndex: c.getData().length - 90, toIndex: c.getData().length - 1 }, false) })
69 + await page.mouse.move(700, 300)
70 + await page.waitForTimeout(300)
71 + const full = `${OUT}/sharp-${tag}${suffix}.png`
72 + await page.screenshot({ path: full })
73 + const clip = { x: 880, y: 220, width: 300, height: 200 }
74 + const crop = await page.screenshot({ clip })
75 + // Magnify ×3 without smoothing.
76 + const zoom = await page.evaluate(async ([b64, w, h, k]) => {
77 + const img = new Image()
78 + img.src = 'data:image/png;base64,' + b64
79 + await img.decode()
80 + const cv = document.createElement('canvas'); cv.width = img.width * k; cv.height = img.height * k
81 + const g = cv.getContext('2d'); g.imageSmoothingEnabled = false
82 + g.drawImage(img, 0, 0, cv.width, cv.height)
83 + return cv.toDataURL('image/png').split(',')[1]
84 + }, [crop.toString('base64'), clip.width, clip.height, 3])
85 + const { writeFileSync } = await import('node:fs')
86 + const zoomFile = `${OUT}/sharp-${tag}${suffix}-zoom.png`
87 + writeFileSync(zoomFile, Buffer.from(zoom, 'base64'))
88 + console.log(`sharp ${tag}${suffix} → ${full} + ${zoomFile}${errors.length ? ` ⚠ ${errors[0]}` : ''}`)
89 + await ctx.close()
90 + }
91 +}
92 +
54 93 async function main() {
55 94 const vite = startVite()
56 95 const stop = () => { try { vite.kill('SIGTERM') } catch { /* ignore */ } }
@@ -59,6 +98,11 @@ async function main() {
59 98 await waitFor(HARNESS)
60 99 const browser = await chromium.launch()
61 100 const results = []
101 + if (args.sharp) {
102 + await sharpShots(browser, args.sharp, (args.dprs || '2').split(',').map(Number))
103 + await browser.close()
104 + return
105 + }
62 106 for (const s of SHOTS) {
63 107 if (ONLY && !ONLY.has(s.name)) continue
64 108 const ctx = await browser.newContext(s.mobile
modified hfmarketdata/web/src/charts/engine/core/chart.js +5 −5
@@ -6,7 +6,7 @@ import { BarStore } from '../data/store.js'
6 6 import { TimeScale } from '../scales/time-scale.js'
7 7 import { timeTicks as buildTimeTicks } from '../scales/ticks.js'
8 8 import { Pane, COLLAPSED_H, MIN_PANE_H } from '../panes/pane.js'
9 −import { createLayer, crisp, withAlpha } from '../render/canvas.js'
9 +import { createLayer, crisp, withAlpha, hair, lw } from '../render/canvas.js'
10 10 import { font, measure, FONT_SIZE } from '../render/text.js'
11 11 import { drawSeries, drawVolume } from '../render/series.js'
12 12 import { drawGrid, drawSessionBreaks, drawPriceAxis, drawTimeAxis, measureAxisWidth, TIME_AXIS_H, MIN_AXIS_W } from '../render/axes.js'
@@ -406,8 +406,8 @@ export class Chart {
406 406 const ctx = p.main.ctx
407 407 if (p !== this.panes[0]) {
408 408 ctx.strokeStyle = this.theme.paneBorder
409 − ctx.lineWidth = 1
410 − ctx.beginPath(); ctx.moveTo(0, 0.5); ctx.lineTo(this.width, 0.5); ctx.stroke()
409 + ctx.lineWidth = hair()
410 + ctx.beginPath(); ctx.moveTo(0, crisp(0)); ctx.lineTo(this.width, crisp(0)); ctx.stroke()
411 411 }
412 412 }
413 413
@@ -429,7 +429,7 @@ export class Chart {
429 429 const ind = this.indicators.get(p.indicators[0])
430 430 if (!ind || !ind.spec.levels) return
431 431 ctx.strokeStyle = this.theme.gridStrong
432 − ctx.lineWidth = 1
432 + ctx.lineWidth = hair()
433 433 ctx.setLineDash([4, 4])
434 434 ctx.beginPath()
435 435 for (const l of ind.spec.levels) { const y = crisp(p.scale.y(l)); ctx.moveTo(0, y); ctx.lineTo(W, y) }
@@ -459,7 +459,7 @@ export class Chart {
459 459 const A = c.aligned
460 460 const pct = new Array(vr.to + 1)
461 461 for (let i = vr.from; i <= vr.to; i++) pct[i] = A[i] > 0 ? (A[i] / c.base - 1) * 100 : null
462 − ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'
462 + ctx.strokeStyle = color; ctx.lineWidth = lw(2); ctx.lineJoin = 'round'
463 463 ctx.stroke(valuePath(pct, vr.from, vr.to, this.ts, v => ps.yInternal(v)))
464 464 // Label at the last visible point.
465 465 let last = vr.to
modified hfmarketdata/web/src/charts/engine/drawings/manager.js +1 −1
@@ -3,7 +3,7 @@
3 3
4 4 import { TOOLS, POINT_COUNT, FIB_LEVELS, newDrawing, serialize, deserialize } from './model.js'
5 5 import { hitDrawing, extendLine, HANDLE_R } from './geometry.js'
6 −import { crisp, withAlpha, roundRect } from '../render/canvas.js'
6 +import { crisp, withAlpha, roundRect, alignFor, hair } from '../render/canvas.js'
7 7 import { font, pill, measure, inkFor } from '../render/text.js'
8 8 import { formatPercent } from '../format/number.js'
9 9 import { fmtDuration, fmtFull } from '../format/time.js'
modified hfmarketdata/web/src/charts/engine/render/axes.js +38 −26
@@ -1,26 +1,27 @@
1 −// Grid, price axis and time axis renderers.
1 +// Grid, price axis and time axis renderers. Every line is one device pixel; text sits on whole device pixels.
2 2
3 −import { crisp, hline, vline, withAlpha } from './canvas.js'
4 −import { font, measure, FONT_SIZE } from './text.js'
3 +import { crisp, hline, vline, withAlpha, hair, snap } from './canvas.js'
4 +import { font, measure, FONT_SIZE, textY } from './text.js'
5 5
6 6 export const TIME_AXIS_H = 26
7 7 export const AXIS_PAD = 8
8 8 export const MIN_AXIS_W = 52
9 9
10 10 /** Horizontal grid lines at price ticks, vertical at time ticks (day/month/year ticks are stronger). */
11 −export function drawGrid(ctx, { width, height, priceTicks, timeTicks, theme }) {
12 − ctx.lineWidth = 1
11 +export function drawGrid(ctx, { width, height, priceTicks, timeTicks, theme, showV = true, showH = true }) {
12 + ctx.lineWidth = hair()
13 13 ctx.strokeStyle = theme.grid
14 14 ctx.beginPath()
15 − for (const t of priceTicks) {
15 + if (showH) for (const t of priceTicks) {
16 16 if (t.y < 0 || t.y > height) continue
17 17 ctx.moveTo(0, crisp(t.y)); ctx.lineTo(width, crisp(t.y))
18 18 }
19 − for (const t of timeTicks) {
19 + if (showV) for (const t of timeTicks) {
20 20 if (t.level >= 4) continue
21 21 ctx.moveTo(crisp(t.x), 0); ctx.lineTo(crisp(t.x), height)
22 22 }
23 23 ctx.stroke()
24 + if (!showV) return
24 25 ctx.strokeStyle = theme.gridStrong
25 26 ctx.beginPath()
26 27 for (const t of timeTicks) {
@@ -30,38 +31,48 @@ export function drawGrid(ctx, { width, height, priceTicks, timeTicks, theme }) {
30 31 ctx.stroke()
31 32 }
32 33
33 −/** Very light vertical lines where a new calendar day starts (intraday only). */
34 +/** Light dashed vertical lines where a new calendar day starts (intraday only). */
34 35 export function drawSessionBreaks(ctx, { xs, height, theme }) {
35 36 if (!xs.length) return
36 − ctx.strokeStyle = withAlpha(theme.axisText, 0.18)
37 − ctx.lineWidth = 1
38 − ctx.setLineDash([2, 4])
37 + ctx.strokeStyle = withAlpha(theme.axisText, 0.30)
38 + ctx.lineWidth = hair()
39 + ctx.setLineDash([2, 3])
39 40 ctx.beginPath()
40 41 for (const x of xs) { ctx.moveTo(crisp(x), 0); ctx.lineTo(crisp(x), height) }
41 42 ctx.stroke()
42 43 ctx.setLineDash([])
43 44 }
44 45
46 +/** Shaded columns for extended-hours bars: [{ x0, x1 }] in CSS px. */
47 +export function drawSessionShade(ctx, { spans, height, theme }) {
48 + if (!spans.length) return
49 + ctx.fillStyle = withAlpha(theme.axisText, 0.05)
50 + for (const s of spans) ctx.fillRect(snap(s.x0), 0, snap(s.x1) - snap(s.x0), height)
51 +}
52 +
45 53 /** Price axis: background, axis line, tick marks and labels. `x0` = left edge of the axis area. */
46 −export function drawPriceAxis(ctx, { x0, width, height, ticks, theme, mono = true, levels = [] }) {
54 +export function drawPriceAxis(ctx, { x0, width, height, ticks, theme, mono = true, side = 'right' }) {
47 55 ctx.fillStyle = theme.bg
48 56 ctx.fillRect(x0, 0, width, height)
49 57 ctx.strokeStyle = theme.axisLine
50 − ctx.lineWidth = 1
51 − vline(ctx, x0, 0, height)
58 + const edge = side === 'right' ? x0 : x0 + width
59 + vline(ctx, edge, 0, height)
52 60 const f = font(theme, { mono, size: FONT_SIZE })
53 61 ctx.font = f
54 62 ctx.fillStyle = theme.axisText
55 − ctx.textBaseline = 'middle'
56 − ctx.textAlign = 'left'
63 + ctx.textBaseline = 'alphabetic'
64 + ctx.textAlign = side === 'right' ? 'left' : 'right'
57 65 ctx.strokeStyle = theme.axisLine
66 + ctx.lineWidth = hair()
67 + const tx = side === 'right' ? snap(x0 + AXIS_PAD) : snap(x0 + width - AXIS_PAD)
58 68 for (const t of ticks) {
59 69 if (t.y < 4 || t.y > height - 4) continue
60 70 const y = crisp(t.y)
61 − ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + 4, y); ctx.stroke()
62 − ctx.fillText(t.text, x0 + AXIS_PAD, t.y + 0.5)
71 + ctx.beginPath()
72 + if (side === 'right') { ctx.moveTo(x0, y); ctx.lineTo(x0 + 4, y) } else { ctx.moveTo(edge - 4, y); ctx.lineTo(edge, y) }
73 + ctx.stroke()
74 + ctx.fillText(t.text, tx, textY(t.y, FONT_SIZE))
63 75 }
64 − void levels
65 76 }
66 77
67 78 /** Width needed by the widest label of a set of ticks (plus padding), never below MIN_AXIS_W. */
@@ -73,28 +84,29 @@ export function measureAxisWidth(ctx, ticks, theme, mono = true) {
73 84 }
74 85
75 86 /** Time axis strip: top line, tick marks and hierarchical labels (strong = day/month/year). */
76 −export function drawTimeAxis(ctx, { width, plotWidth, height, ticks, theme, mono = true }) {
87 +export function drawTimeAxis(ctx, { width, plotWidth, height, ticks, theme, mono = true, plotX0 = 0 }) {
77 88 ctx.fillStyle = theme.bg
78 89 ctx.fillRect(0, 0, width, height)
79 90 ctx.strokeStyle = theme.axisLine
80 − ctx.lineWidth = 1
81 91 hline(ctx, 0, width, 0)
82 − vline(ctx, plotWidth, 0, height)
83 − ctx.textBaseline = 'middle'
92 + vline(ctx, plotX0 + plotWidth, 0, height)
93 + if (plotX0 > 0) vline(ctx, plotX0, 0, height)
94 + ctx.textBaseline = 'alphabetic'
84 95 ctx.textAlign = 'center'
85 96 const regular = font(theme, { mono, size: FONT_SIZE })
86 97 const strong = font(theme, { mono, size: FONT_SIZE, weight: 700 })
98 + const ty = textY(height / 2 + 1, FONT_SIZE)
99 + ctx.lineWidth = hair()
87 100 for (const t of ticks) {
88 101 if (t.x < 0 || t.x > plotWidth) continue
89 102 ctx.strokeStyle = theme.axisLine
90 − ctx.beginPath(); ctx.moveTo(crisp(t.x), 0); ctx.lineTo(crisp(t.x), 4); ctx.stroke()
103 + ctx.beginPath(); ctx.moveTo(crisp(plotX0 + t.x), 0); ctx.lineTo(crisp(plotX0 + t.x), 4); ctx.stroke()
91 104 ctx.font = t.strong ? strong : regular
92 105 ctx.fillStyle = t.strong ? theme.text : theme.axisText
93 − // Keep labels inside the plot: shift edge labels inward.
94 106 const w = measure(ctx, t.text, ctx.font)
95 107 let x = t.x
96 108 if (x - w / 2 < 2) x = 2 + w / 2
97 109 if (x + w / 2 > plotWidth - 2) x = plotWidth - 2 - w / 2
98 − ctx.fillText(t.text, x, height / 2 + 2.5)
110 + ctx.fillText(t.text, snap(plotX0 + x), ty)
99 111 }
100 112 }
modified hfmarketdata/web/src/charts/engine/render/canvas.js +34 −7
@@ -1,4 +1,13 @@
1 −// Canvas layers with devicePixelRatio handling and crisp-line helpers. Drawing happens in CSS pixels.
1 +// Canvas layers with devicePixelRatio handling and device-pixel-crisp helpers.
2 +//
3 +// Drawing happens in CSS pixels on a context scaled by the DPR, but every "hairline" (grid, wicks, borders,
4 +// crosshair, axes) is exactly ONE DEVICE PIXEL wide and centered on a device pixel: at DPR 2 that is a 0.5 CSS px
5 +// stroke at (Math.round(v * dpr) + 0.5) / dpr. Rendering is synchronous and single-threaded, so the DPR of the
6 +// layer being painted is kept in a module-level value set by `layer.clear()` (and by `setDpr` for exports) — the
7 +// helpers below (`crisp`, `snap`, `hair`, `lw`) read it, which keeps every renderer free of DPR plumbing.
8 +
9 +export let DPR = 1
10 +export function setDpr(d) { DPR = d > 0 ? d : 1 }
2 11
3 12 export function createLayer(parent, { zIndex = 0, pointerEvents = 'none' } = {}) {
4 13 const canvas = document.createElement('canvas')
@@ -19,24 +28,42 @@ export function createLayer(parent, { zIndex = 0, pointerEvents = 'none' } = {})
19 28 ctx.setTransform(1, 0, 0, 1, 0, 0)
20 29 ctx.clearRect(0, 0, canvas.width, canvas.height)
21 30 ctx.setTransform(layer.dpr, 0, 0, layer.dpr, 0, 0)
31 + setDpr(layer.dpr)
32 + // Text: geometric precision + no kerning so digits stay tabular in the mono axis font.
33 + if ('textRendering' in ctx) ctx.textRendering = 'geometricPrecision'
34 + if ('fontKerning' in ctx) ctx.fontKerning = 'none'
22 35 },
23 36 destroy() { canvas.remove() },
24 37 }
25 38 return layer
26 39 }
27 40
28 −/** Snap a coordinate to the pixel center for 1 px strokes. */
29 −export const crisp = v => Math.round(v) + 0.5
30 −/** Snap to a pixel edge (for fills). */
31 −export const snap = v => Math.round(v)
41 +/** Snap a CSS coordinate to the center of a device pixel (for one-device-pixel strokes). */
42 +export const crisp = v => (Math.round(v * DPR) + 0.5) / DPR
43 +/** Snap a CSS coordinate to a device pixel edge (for fills and text positions). */
44 +export const snap = v => Math.round(v * DPR) / DPR
45 +/** Width of one device pixel in CSS px. */
46 +export const hair = () => 1 / DPR
47 +/** Stroke width for `n` device pixels (never below 0.75 CSS px so DPR 3 lines stay readable). */
48 +export const lw = n => Math.max(n / DPR, 0.75)
49 +/** Round a CSS length to a whole number of device pixels (at least one). */
50 +export const px = v => Math.max(1 / DPR, Math.round(v * DPR) / DPR)
51 +
52 +/**
53 + * Align a coordinate for a stroke of `widthCss` CSS px: odd device-pixel widths center on a device pixel,
54 + * even widths on a device pixel edge — either way the stroke covers whole device pixels (no half-covered rows).
55 + */
56 +export function alignFor(widthCss, v) { const n = Math.max(1, Math.round(widthCss * DPR)); return n % 2 ? crisp(v) : snap(v) }
32 57
33 −/** 1 px horizontal line. */
58 +/** One-device-pixel horizontal line. */
34 59 export function hline(ctx, x0, x1, y) {
60 + ctx.lineWidth = hair()
35 61 ctx.beginPath(); ctx.moveTo(x0, crisp(y)); ctx.lineTo(x1, crisp(y)); ctx.stroke()
36 62 }
37 63
38 −/** 1 px vertical line. */
64 +/** One-device-pixel vertical line. */
39 65 export function vline(ctx, x, y0, y1) {
66 + ctx.lineWidth = hair()
40 67 ctx.beginPath(); ctx.moveTo(crisp(x), y0); ctx.lineTo(crisp(x), y1); ctx.stroke()
41 68 }
42 69
modified hfmarketdata/web/src/charts/engine/render/overlay.js +12 −10
@@ -1,14 +1,14 @@
1 1 // Overlay-layer renderers: crosshair, last price marker, visible High/Low markers, watermark, pane header buttons.
2 2
3 −import { crisp, roundRect, withAlpha } from './canvas.js'
4 −import { font, pill, measure, FONT_SIZE, inkFor } from './text.js'
3 +import { crisp, roundRect, withAlpha, hair, snap } from './canvas.js'
4 +import { font, pill, measure, FONT_SIZE, inkFor, textY } from './text.js'
5 5
6 6 export const LABEL_H = 18
7 7
8 −/** Crosshair lines for one pane. `y` is null in panes other than the hovered one. */
8 +/** Crosshair lines for one pane (one device pixel, dashed). `y` is null in panes other than the hovered one. */
9 9 export function drawCrosshair(ctx, { x, y, width, height, theme }) {
10 10 ctx.strokeStyle = theme.crosshair
11 − ctx.lineWidth = 1
11 + ctx.lineWidth = hair()
12 12 ctx.setLineDash([3, 3])
13 13 ctx.beginPath()
14 14 if (x != null) { ctx.moveTo(crisp(x), 0); ctx.lineTo(crisp(x), height) }
@@ -42,7 +42,7 @@ export function drawLastPrice(ctx, { y, text, plotWidth, axisWidth, height, up,
42 42 const color = up ? theme.lastPriceUp : theme.lastPriceDown
43 43 if (line) {
44 44 ctx.strokeStyle = withAlpha(color, 0.9)
45 − ctx.lineWidth = 1
45 + ctx.lineWidth = hair()
46 46 ctx.setLineDash([2, 3])
47 47 ctx.beginPath(); ctx.moveTo(0, crisp(y)); ctx.lineTo(plotWidth, crisp(y)); ctx.stroke()
48 48 ctx.setLineDash([])
@@ -65,7 +65,7 @@ export function drawLastPrice(ctx, { y, text, plotWidth, axisWidth, height, up,
65 65 export function drawHiLoMarkers(ctx, { hi, lo, plotWidth, theme }) {
66 66 const f = font(theme, { mono: true, size: 10 })
67 67 ctx.font = f
68 − ctx.textBaseline = 'middle'
68 + ctx.textBaseline = 'alphabetic'
69 69 const place = (m, above) => {
70 70 if (!m) return
71 71 const w = measure(ctx, m.text, f)
@@ -73,10 +73,10 @@ export function drawHiLoMarkers(ctx, { hi, lo, plotWidth, theme }) {
73 73 x = Math.max(2, Math.min(plotWidth - w - 2, x))
74 74 const y = above ? m.y - 9 : m.y + 9
75 75 ctx.fillStyle = withAlpha(theme.bg, 0.75)
76 − ctx.fillRect(Math.round(x) - 2, Math.round(y) - 7, Math.ceil(w) + 4, 14)
76 + ctx.fillRect(snap(x) - 2, snap(y) - 7, Math.ceil(w) + 4, 14)
77 77 ctx.fillStyle = theme.axisText
78 78 ctx.textAlign = 'left'
79 − ctx.fillText(m.text, Math.round(x), y + 0.5)
79 + ctx.fillText(m.text, snap(x), textY(y, 10))
80 80 }
81 81 place(hi, true)
82 82 place(lo, false)
@@ -98,10 +98,10 @@ export function drawWatermark(ctx, { text, width, height, theme, alpha = 0.07 })
98 98 export function drawPaneHeader(ctx, { title, plotWidth, theme, hover, collapsed }) {
99 99 const f = font(theme, { size: FONT_SIZE })
100 100 ctx.font = f
101 − ctx.textBaseline = 'middle'
101 + ctx.textBaseline = 'alphabetic'
102 102 ctx.textAlign = 'left'
103 103 ctx.fillStyle = theme.textMuted
104 − if (title) ctx.fillText(title, 8, 11)
104 + if (title) ctx.fillText(title, 8, textY(11, FONT_SIZE))
105 105 const size = 16, gap = 4
106 106 const boxes = []
107 107 const buttons = [{ id: 'close', glyph: '×' }, { id: 'collapse', glyph: collapsed ? '+' : '−' }]
@@ -113,8 +113,10 @@ export function drawPaneHeader(ctx, { title, plotWidth, theme, hover, collapsed
113 113 roundRect(ctx, box.x, box.y, box.w, box.h, 3); ctx.fill()
114 114 ctx.fillStyle = isHover ? theme.text : theme.textMuted
115 115 ctx.textAlign = 'center'
116 + ctx.textBaseline = 'middle'
116 117 ctx.font = font(theme, { size: 13, weight: 500 })
117 118 ctx.fillText(b.glyph, box.x + size / 2, box.y + size / 2 + 0.5)
119 + ctx.textBaseline = 'alphabetic'
118 120 boxes.push(box)
119 121 x -= size + gap
120 122 }
modified hfmarketdata/web/src/charts/engine/render/plots.js +12 −9
@@ -1,6 +1,9 @@
1 1 // Indicator plot renderers (line / histogram / band / cloud) and comparison overlays.
2 2
3 −import { withAlpha } from './canvas.js'
3 +import { withAlpha, DPR, lw, snap } from './canvas.js'
4 +
5 +/** Default indicator line width: 1.5 device pixels (a plot may override with `width`, in device pixels). */
6 +export const PLOT_LINE_DEVICE_PX = 1.5
4 7
5 8 /** Resolve a plot color spec (series index or theme role) to a CSS color. */
6 9 export function plotColor(spec, theme, colors) {
@@ -77,7 +80,7 @@ export function drawPlots(g) {
77 80 const v = values[p.key]
78 81 if (!v) continue
79 82 ctx.strokeStyle = plotColor(p.color, theme, colors)
80 − ctx.lineWidth = p.width ?? (ts.isCompressed ? 1 : 1.25)
83 + ctx.lineWidth = lw(p.width ?? (ts.isCompressed ? 1 : PLOT_LINE_DEVICE_PX))
81 84 ctx.lineJoin = 'round'; ctx.lineCap = 'round'
82 85 if (p.dash) ctx.setLineDash(p.dash)
83 86 ctx.stroke(valuePath(v, from, to, ts, yOf))
@@ -148,19 +151,19 @@ function closeRegion(path, back) {
148 151 }
149 152
150 153 function drawHistogram(ctx, v, from, to, ts, yOf, height, p, theme, colors, store) {
151 − const w = ts.isCompressed ? 1 : Math.max(1, ts.bodyWidth())
152 − const half = (w - 1) / 2
154 + const one = 1 / DPR
155 + const w = ts.isCompressed ? one : ts.bodyWidthDevice(DPR) / DPR
153 156 const pos = new Path2D(), neg = new Path2D()
154 − const zero = p.color === 'volume' ? height : Math.round(yOf(0))
157 + const zero = p.color === 'volume' ? height : snap(yOf(0))
155 158 const end = Math.min(to, v.length - 1)
156 159 const add = (i, val, xc) => {
157 − const y = Math.round(yOf(val))
160 + const y = snap(yOf(val))
158 161 let bucket
159 162 if (p.color === 'volume') bucket = store && store.c[i] < store.o[i] ? neg : pos
160 163 else if (p.color === 'updown') bucket = val >= 0 ? pos : neg
161 164 else bucket = pos
162 − const top = Math.min(y, zero), h = Math.max(1, Math.abs(zero - y))
163 − bucket.rect(xc - half - 0.5, top, w, h)
165 + const top = Math.min(y, zero), h = Math.max(one, Math.abs(zero - y))
166 + bucket.rect(xc - w / 2, top, w, h)
164 167 }
165 168 if (ts.barSpacing < 1) {
166 169 // Per pixel column: keep the largest magnitude.
@@ -178,7 +181,7 @@ function drawHistogram(ctx, v, from, to, ts, yOf, height, p, theme, colors, stor
178 181 for (let i = Math.max(0, from); i <= end; i++) {
179 182 const val = v[i]
180 183 if (val == null || Number.isNaN(val)) continue
181 − add(i, val, Math.floor(ts.x(i)) + 0.5)
184 + add(i, val, (Math.floor(ts.x(i) * DPR) + 0.5) / DPR)
182 185 }
183 186 }
184 187 if (p.color === 'volume') {
modified hfmarketdata/web/src/charts/engine/render/series.js +58 −46
@@ -1,11 +1,16 @@
1 1 // Series renderers. All work on the store's typed columns, cull to [from, to] and batch by color with Path2D
2 −// (no per-bar objects). Coordinates are CSS pixels; the context is already scaled for the DPR.
2 +// (no per-bar objects). Coordinates are CSS pixels; the context is already scaled for the DPR, and every edge is
3 +// snapped to DEVICE pixels (see canvas.js): wicks are one device pixel wide, bodies have an odd device-pixel width
4 +// centered on the wick, gaps between bodies are ≥ 1 device pixel, outlines are one device pixel.
3 5
4 −import { withAlpha } from './canvas.js'
6 +import { withAlpha, mix, DPR, hair, lw, snap } from './canvas.js'
5 7 import { seriesPath } from './plots.js'
6 8
7 −/** Pixel-center x for bar `i` (odd body widths center on a pixel). */
8 −const centerX = (ts, i) => Math.floor(ts.x(i)) + 0.5
9 +/** Device-pixel center x for bar `i` (odd body widths center on a device pixel). */
10 +const centerX = (ts, i) => (Math.floor(ts.x(i) * DPR) + 0.5) / DPR
11 +
12 +/** Slightly darker version of a body color, for the one-device-pixel outline that gives candles depth. */
13 +export const outlineColor = c => mix(c, '#000000', 0.28)
9 14
10 15 /**
11 16 * @param g { ctx, store, from, to, ts, ps, theme, width, height, type, baseline, lineWidth }
@@ -13,14 +18,14 @@ const centerX = (ts, i) => Math.floor(ts.x(i)) + 0.5
13 18 export function drawSeries(g) {
14 19 const { type } = g
15 20 if (g.to < g.from) return
16 − if (g.ts.isCompressed && type !== 'area' && type !== 'baseline' && type !== 'columns') return drawLine(g, g.theme.neutral, 1)
21 + if (g.ts.isCompressed && type !== 'area' && type !== 'baseline' && type !== 'columns') return drawLine(g, g.theme.neutral, lw(1))
17 22 switch (type) {
18 23 case 'candles': return drawCandles(g, false)
19 24 case 'hollow': return drawCandles(g, true)
20 25 case 'heikin': return drawCandles(g, false)
21 26 case 'ohlc': return drawBars(g, true)
22 27 case 'hlc': return drawBars(g, false)
23 − case 'line': return drawLine(g, g.theme.series[0], g.lineWidth || 1.5)
28 + case 'line': return drawLine(g, g.theme.series[0], lw(g.lineWidth || 2))
24 29 case 'area': return drawArea(g)
25 30 case 'baseline': return drawBaseline(g)
26 31 case 'columns': return drawColumns(g)
@@ -31,51 +36,57 @@ export function drawSeries(g) {
31 36 function drawCandles(g, hollow) {
32 37 const { ctx, store, from, to, ts, ps, theme } = g
33 38 const O = store.o, H = store.h, L = store.l, C = store.c
34 − const bodyW = ts.bodyWidth()
35 − const half = (bodyW - 1) / 2
39 + const d = DPR, one = 1 / d
40 + const bodyWd = ts.bodyWidthDevice(d) // device px, odd
41 + const bodyW = bodyWd / d // CSS px
42 + const halfW = bodyW / 2
43 + const outline = bodyWd >= 3
36 44 const upBody = new Path2D(), downBody = new Path2D(), upWick = new Path2D(), downWick = new Path2D()
37 − const upHollow = hollow ? new Path2D() : null
45 + const upEdge = new Path2D(), downEdge = new Path2D()
38 46 for (let i = from; i <= to; i++) {
39 47 const o = O[i], c = C[i]
40 48 const up = c >= o
41 49 const xc = centerX(ts, i)
42 50 const yO = ps.y(o), yC = ps.y(c)
43 − let top = Math.round(Math.min(yO, yC)), bot = Math.round(Math.max(yO, yC))
44 − if (bot - top < 1) bot = top + 1
45 − const yH = Math.round(ps.y(H[i])), yL = Math.round(ps.y(L[i]))
51 + let top = snap(Math.min(yO, yC)), bot = snap(Math.max(yO, yC))
52 + if (bot - top < one) bot = top + one
53 + const yH = snap(ps.y(H[i])), yL = snap(ps.y(L[i]))
46 54 const wick = up ? upWick : downWick
47 − // Wicks are 1 px wide, centered on the body, drawn only outside the body.
48 − if (yH < top) wick.rect(xc - 0.5, yH, 1, top - yH)
49 − if (yL > bot) wick.rect(xc - 0.5, bot, 1, yL - bot)
50 − if (bodyW === 1) { wick.rect(xc - 0.5, top, 1, bot - top); continue }
51 − const left = xc - half - 0.5
52 − if (hollow && up) upHollow.rect(left + 0.5, top + 0.5, bodyW - 1, Math.max(1, bot - top - 1))
53 − else (up ? upBody : downBody).rect(left, top, bodyW, bot - top)
55 + // Wicks: one device pixel wide, centered on the body, drawn only outside the body.
56 + if (yH < top) wick.rect(xc - one / 2, yH, one, top - yH)
57 + if (yL > bot) wick.rect(xc - one / 2, bot, one, yL - bot)
58 + if (bodyWd === 1) { wick.rect(xc - one / 2, top, one, bot - top); continue }
59 + const left = xc - halfW
60 + if (hollow && up) { upEdge.rect(left + one / 2, top + one / 2, bodyW - one, Math.max(one, bot - top - one)); continue }
61 + ;(up ? upBody : downBody).rect(left, top, bodyW, bot - top)
62 + if (outline && bot - top >= 3 * one) (up ? upEdge : downEdge).rect(left + one / 2, top + one / 2, bodyW - one, bot - top - one)
54 63 }
55 64 ctx.fillStyle = theme.upWick; ctx.fill(upWick)
56 65 ctx.fillStyle = theme.downWick; ctx.fill(downWick)
57 66 ctx.fillStyle = theme.up; ctx.fill(upBody)
58 67 ctx.fillStyle = theme.down; ctx.fill(downBody)
59 − if (hollow) {
60 − ctx.strokeStyle = theme.up; ctx.lineWidth = 1; ctx.stroke(upHollow)
61 − }
68 + ctx.lineWidth = one
69 + if (hollow) { ctx.strokeStyle = theme.up; ctx.stroke(upEdge) } else if (outline) { ctx.strokeStyle = outlineColor(theme.up); ctx.stroke(upEdge) }
70 + if (outline) { ctx.strokeStyle = outlineColor(theme.down); ctx.stroke(downEdge) }
62 71 }
63 72
64 73 function drawBars(g, withOpen) {
65 74 const { ctx, store, from, to, ts, ps, theme } = g
66 75 const O = store.o, H = store.h, L = store.l, C = store.c
67 − const bodyW = ts.bodyWidth()
68 − const tick = Math.max(1, Math.floor(bodyW / 2))
76 + const d = DPR, one = 1 / d
77 + const bodyWd = ts.bodyWidthDevice(d)
78 + const tick = Math.max(one, Math.floor(bodyWd / 2) / d)
79 + const th = bodyWd >= 5 ? 2 * one : one // OHLC bars get a 2-device-pixel stem once there is room
69 80 const up = new Path2D(), down = new Path2D()
70 81 for (let i = from; i <= to; i++) {
71 82 const o = O[i], c = C[i]
72 83 const p = c >= o ? up : down
73 − const xc = centerX(ts, i)
74 − const yH = Math.round(ps.y(H[i])), yL = Math.round(ps.y(L[i]))
75 − p.rect(xc - 0.5, yH, 1, Math.max(1, yL - yH))
76 − const yC = Math.round(ps.y(c))
77 − p.rect(xc - 0.5, yC, tick + 0.5, 1)
78 − if (withOpen) { const yO = Math.round(ps.y(o)); p.rect(xc - tick, yO, tick + 0.5, 1) }
84 + const xc = centerX(ts, i) // device pixel center → xc − th/2 is a device pixel edge
85 + const yH = snap(ps.y(H[i])), yL = snap(ps.y(L[i]))
86 + p.rect(xc - th / 2, yH, th, Math.max(one, yL - yH))
87 + const yC = snap(ps.y(c))
88 + p.rect(xc, yC - (th - one), tick + th / 2, th)
89 + if (withOpen) { const yO = snap(ps.y(o)); p.rect(xc - tick - th / 2, yO - (th - one), tick + th / 2, th) }
79 90 }
80 91 ctx.fillStyle = theme.up; ctx.fill(up)
81 92 ctx.fillStyle = theme.down; ctx.fill(down)
@@ -94,7 +105,7 @@ function drawLine(g, color, width) {
94 105 }
95 106
96 107 function drawArea(g) {
97 − const { ctx, store, from, to, ts, ps, theme, height } = g
108 + const { ctx, from, to, ts, theme, height } = g
98 109 const color = theme.series[0]
99 110 const line = closePath(g)
100 111 const fill = new Path2D(line)
@@ -102,8 +113,7 @@ function drawArea(g) {
102 113 const grad = ctx.createLinearGradient(0, 0, 0, height)
103 114 grad.addColorStop(0, withAlpha(color, 0.35)); grad.addColorStop(1, withAlpha(color, 0.02))
104 115 ctx.fillStyle = grad; ctx.fill(fill)
105 − ctx.strokeStyle = color; ctx.lineWidth = g.ts.isCompressed ? 1 : 1.5; ctx.lineJoin = 'round'; ctx.stroke(line)
106 − void store
116 + ctx.strokeStyle = color; ctx.lineWidth = lw(g.ts.isCompressed ? 1.5 : 2); ctx.lineJoin = 'round'; ctx.stroke(line)
107 117 }
108 118
109 119 function drawBaseline(g) {
@@ -113,34 +123,36 @@ function drawBaseline(g) {
113 123 const line = closePath(g)
114 124 const fill = new Path2D(line)
115 125 fill.lineTo(ts.x(to), yB); fill.lineTo(ts.x(from), yB); fill.closePath()
126 + const w = lw(2)
116 127 // Above the baseline: up color; below: down color — two clipped fills of the same polygon.
117 128 ctx.save(); ctx.beginPath(); ctx.rect(0, 0, width, yB); ctx.clip()
118 129 let grad = ctx.createLinearGradient(0, 0, 0, yB)
119 130 grad.addColorStop(0, withAlpha(theme.up, 0.35)); grad.addColorStop(1, withAlpha(theme.up, 0.03))
120 131 ctx.fillStyle = grad; ctx.fill(fill)
121 − ctx.strokeStyle = theme.up; ctx.lineWidth = 1.5; ctx.stroke(line)
132 + ctx.strokeStyle = theme.up; ctx.lineWidth = w; ctx.stroke(line)
122 133 ctx.restore()
123 134 ctx.save(); ctx.beginPath(); ctx.rect(0, yB, width, height - yB); ctx.clip()
124 135 grad = ctx.createLinearGradient(0, yB, 0, height)
125 136 grad.addColorStop(0, withAlpha(theme.down, 0.03)); grad.addColorStop(1, withAlpha(theme.down, 0.35))
126 137 ctx.fillStyle = grad; ctx.fill(fill)
127 − ctx.strokeStyle = theme.down; ctx.lineWidth = 1.5; ctx.stroke(line)
138 + ctx.strokeStyle = theme.down; ctx.lineWidth = w; ctx.stroke(line)
128 139 ctx.restore()
129 − ctx.strokeStyle = withAlpha(theme.textMuted, 0.6); ctx.lineWidth = 1; ctx.setLineDash([3, 3])
130 − ctx.beginPath(); ctx.moveTo(0, Math.round(yB) + 0.5); ctx.lineTo(width, Math.round(yB) + 0.5); ctx.stroke(); ctx.setLineDash([])
140 + ctx.strokeStyle = withAlpha(theme.textMuted, 0.6); ctx.lineWidth = hair(); ctx.setLineDash([3, 3])
141 + const yb = (Math.round(yB * DPR) + 0.5) / DPR
142 + ctx.beginPath(); ctx.moveTo(0, yb); ctx.lineTo(width, yb); ctx.stroke(); ctx.setLineDash([])
131 143 }
132 144
133 145 function drawColumns(g) {
134 146 const { ctx, store, from, to, ts, ps, theme, height } = g
135 147 const O = store.o, C = store.c
136 − const w = Math.max(1, ts.isCompressed ? 1 : ts.bodyWidth())
137 − const half = (w - 1) / 2
148 + const d = DPR, one = 1 / d
149 + const w = ts.isCompressed ? one : ts.bodyWidthDevice(d) / d
138 150 const up = new Path2D(), down = new Path2D()
139 151 for (let i = from; i <= to; i++) {
140 152 const c = C[i]
141 153 const xc = centerX(ts, i)
142 − const y = Math.round(ps.y(c))
143 − ;(c >= O[i] ? up : down).rect(xc - half - 0.5, y, w, Math.max(1, height - y))
154 + const y = snap(ps.y(c))
155 + ;(c >= O[i] ? up : down).rect(xc - w / 2, y, w, Math.max(one, height - y))
144 156 }
145 157 ctx.fillStyle = withAlpha(theme.up, 0.8); ctx.fill(up)
146 158 ctx.fillStyle = withAlpha(theme.down, 0.8); ctx.fill(down)
@@ -155,13 +167,13 @@ export function drawVolume(g, fraction = 0.2) {
155 167 for (let i = from; i <= to; i++) if (V[i] > max) max = V[i]
156 168 if (!(max > 0)) return
157 169 const areaH = height * fraction
158 − const w = ts.isCompressed ? 1 : Math.max(1, ts.bodyWidth())
159 − const half = (w - 1) / 2
170 + const d = DPR, one = 1 / d
171 + const w = ts.isCompressed ? one : ts.bodyWidthDevice(d) / d
160 172 const up = new Path2D(), down = new Path2D()
161 173 if (ts.barSpacing < 1) {
162 174 // One bar per pixel column: tallest volume of the column, colored by that bar's direction.
163 175 let col = null, best = 0, bestUp = true
164 − const flush = () => { if (col !== null && best > 0) { const h = Math.max(1, Math.round((best / max) * areaH)); (bestUp ? up : down).rect(col, height - h, 1, h) } }
176 + const flush = () => { if (col !== null && best > 0) { const h = Math.max(one, snap((best / max) * areaH)); (bestUp ? up : down).rect(col, height - h, 1, h) } }
165 177 for (let i = from; i <= to; i++) {
166 178 const x = Math.floor(ts.x(i))
167 179 if (x !== col) { flush(); col = x; best = 0 }
@@ -173,9 +185,9 @@ export function drawVolume(g, fraction = 0.2) {
173 185 for (let i = from; i <= to; i++) {
174 186 const v = V[i]
175 187 if (!(v > 0)) continue
176 − const h = Math.max(1, Math.round((v / max) * areaH))
188 + const h = Math.max(one, snap((v / max) * areaH))
177 189 const xc = centerX(ts, i)
178 − ;(C[i] >= O[i] ? up : down).rect(xc - half - 0.5, height - h, w, h)
190 + ;(C[i] >= O[i] ? up : down).rect(xc - w / 2, height - h, w, h)
179 191 }
180 192 }
181 193 ctx.fillStyle = theme.volumeUp; ctx.fill(up)
modified hfmarketdata/web/src/charts/engine/render/text.js +20 −9
@@ -1,9 +1,10 @@
1 −// Text helpers: font strings, measurement cache and axis "pill" labels.
1 +// Text helpers: font strings, measurement cache, device-pixel text placement and axis "pill" labels.
2 2
3 −import { roundRect, luminance } from './canvas.js'
3 +import { roundRect, luminance, snap } from './canvas.js'
4 4
5 5 export const FONT_SIZE = 11
6 6 export const FONT_SIZE_LG = 12
7 +export const PILL_RADIUS = 2
7 8
8 9 export function font(theme, { size = FONT_SIZE, weight = 400, mono = false } = {}) {
9 10 return `${weight} ${size}px ${mono ? theme.mono : theme.font}`
@@ -26,26 +27,36 @@ export function measure(ctx, text, fontStr) {
26 27 }
27 28
28 29 /**
29 − * Filled label with rounded corners. `align`: 'left' (x is the left edge), 'right' (x is the right edge) or
30 − * 'center'. Returns the label box { x, y, w, h }.
30 + * Alphabetic baseline (snapped to a device pixel) that visually centers digits/caps of a `size` px font on
31 + * `centerY`. Digits and capitals sit on the baseline and rise ≈ 0.72 × size, so the optical center is at
32 + * baseline − 0.36 × size. Using the alphabetic baseline (instead of 'middle') makes every label land on the same
33 + * device row for the same y, which is what keeps axis text crisp and aligned.
31 34 */
32 −export function pill(ctx, text, x, y, { bg, color, fontStr, padX = 5, h = 18, align = 'left', radius = 3, clampTo = null, mono = true } = {}) {
35 +export function textY(centerY, size = FONT_SIZE) { return snap(centerY + size * 0.36) }
36 +
37 +/**
38 + * Filled label with rounded corners (2 px). `align`: 'left' (x is the left edge), 'right' (x is the right edge)
39 + * or 'center'. The box is snapped to device pixels and the text is optically centered. Returns { x, y, w, h }.
40 + */
41 +export function pill(ctx, text, x, y, { bg, color, fontStr, padX = 5, h = 18, align = 'left', radius = PILL_RADIUS, clampTo = null, size = FONT_SIZE } = {}) {
33 42 ctx.font = fontStr
34 − const w = Math.ceil(ctx.measureText(text).width) + padX * 2
43 + const tw = ctx.measureText(text).width
44 + const w = Math.ceil(tw) + padX * 2
35 45 let bx = align === 'right' ? x - w : align === 'center' ? x - w / 2 : x
36 46 let by = y - h / 2
37 47 if (clampTo) {
38 48 bx = Math.min(Math.max(bx, clampTo.x0), clampTo.x1 - w)
39 49 by = Math.min(Math.max(by, clampTo.y0), clampTo.y1 - h)
40 50 }
41 − bx = Math.round(bx); by = Math.round(by)
51 + bx = snap(bx); by = snap(by)
42 52 ctx.fillStyle = bg
43 53 roundRect(ctx, bx, by, w, h, radius)
44 54 ctx.fill()
45 55 ctx.fillStyle = color || (luminance(bg) > 0.5 ? '#12161d' : '#ffffff')
46 − ctx.textBaseline = 'middle'
56 + ctx.textBaseline = 'alphabetic'
57 + ctx.textAlign = 'center'
58 + ctx.fillText(text, bx + w / 2, textY(by + h / 2, size))
47 59 ctx.textAlign = 'left'
48 − ctx.fillText(text, bx + padX, by + h / 2 + 0.5)
49 60 return { x: bx, y: by, w, h }
50 61 }
51 62
modified hfmarketdata/web/src/charts/engine/scales/time-scale.js +10 −4
@@ -94,11 +94,17 @@ export class TimeScale {
94 94 /** Older bars were prepended: shift so the viewport stays visually identical. */
95 95 onPrepend(n) { this.leftIndex += n; this.count += n }
96 96
97 − /** Pixel width of the candle body for the current spacing: odd, ≥ 1, leaving ≥ 1 px gap. */
98 − bodyWidth() {
99 − const bs = this.barSpacing
97 + /** Pixel width of the candle body for the current spacing: odd, ≥ 1, leaving ≥ 1 px gap (CSS px, DPR 1). */
98 + bodyWidth() { return this.bodyWidthDevice(1) }
99 +
100 + /**
101 + * Candle body width in DEVICE pixels for a given DPR: odd (so the body centers on the one-device-pixel wick),
102 + * ≥ 1 and leaving a gap of at least one device pixel between neighbouring bodies.
103 + */
104 + bodyWidthDevice(dpr = 1) {
105 + const bs = this.barSpacing * dpr
100 106 if (bs < 2) return 1
101 − let w = Math.floor(bs - Math.max(1, bs * 0.25))
107 + let w = Math.floor(bs - Math.max(1, bs * 0.22))
102 108 if (w % 2 === 0) w -= 1
103 109 return Math.max(1, w)
104 110 }
modified hfmarketdata/web/src/charts/engine/theme.js +10 −10
@@ -4,11 +4,11 @@
4 4 export const darkTheme = {
5 5 bg: '#10131a',
6 6 paneBorder: '#232833',
7 − grid: 'rgba(255,255,255,0.045)',
8 − gridStrong: 'rgba(255,255,255,0.10)',
9 − axisText: '#8f98a8',
10 − axisLine: '#313847',
11 − crosshair: 'rgba(180,188,200,0.55)',
7 + grid: 'rgba(255,255,255,0.06)',
8 + gridStrong: 'rgba(255,255,255,0.13)',
9 + axisText: '#a9b2c1',
10 + axisLine: '#363d4c',
11 + crosshair: 'rgba(190,198,210,0.7)',
12 12 crosshairLabelBg: '#313847',
13 13 crosshairLabelText: '#e8ebf1',
14 14 up: '#2fbf71',
@@ -34,11 +34,11 @@ export const darkTheme = {
34 34 export const lightTheme = {
35 35 bg: '#ffffff',
36 36 paneBorder: '#e2e5eb',
37 − grid: 'rgba(18,22,29,0.055)',
38 − gridStrong: 'rgba(18,22,29,0.12)',
39 − axisText: '#4f5a6b',
40 − axisLine: '#cfd4dd',
41 − crosshair: 'rgba(59,68,82,0.55)',
37 + grid: 'rgba(18,22,29,0.07)',
38 + gridStrong: 'rgba(18,22,29,0.15)',
39 + axisText: '#3f4a5c',
40 + axisLine: '#c3c9d3',
41 + crosshair: 'rgba(59,68,82,0.7)',
42 42 crosshairLabelBg: '#3b4452',
43 43 crosshairLabelText: '#ffffff',
44 44 up: '#15803d',
45 45