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>
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 | |