Skip to content

Commit 69afd5b

Browse files
norwndchappjc
authored andcommitted
ui: consistent rate formatting
1 parent 529e0e8 commit 69afd5b

8 files changed

Lines changed: 160 additions & 93 deletions

File tree

client/webserver/site/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"watch": "webpack --watch --config webpack/dev.js",
99
"analyze": "webpack --config webpack/analyze.js",
1010
"build": "npm exec tsc && webpack --config webpack/prod.js --progress --color",
11+
"build_dev": "npm exec tsc && webpack --config webpack/dev.js --progress --color",
1112
"lint": "npm exec tsc && npm exec -- eslint src --ext .js --ext .ts",
1213
"check-types": "npm exec tsc"
1314
},

client/webserver/site/src/html/bodybuilder.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@
104104
{{end}}
105105

106106
{{define "bottom"}}
107-
<script src="/js/entry.js?v=LVFHWeVTq"></script>
107+
<script src="/js/entry.js?v=LFgBaLTq"></script>
108108
</body>
109109
</html>
110110
{{end}}

client/webserver/site/src/js/charts.ts

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ export class DepthChart extends Chart {
385385
book: OrderBook
386386
zoomLevel: number
387387
lotSize: number
388-
rateStep: number
388+
conventionalRateStep: number
389389
lines: DepthLine[]
390390
markers: Record<string, DepthMarker[]>
391391
zoomInBttn: Region
@@ -454,11 +454,10 @@ export class DepthChart extends Chart {
454454
}
455455

456456
// set sets the current data set and draws.
457-
set (book: OrderBook, lotSize: number, rateStep: number, baseUnitInfo: UnitInfo, quoteUnitInfo: UnitInfo) {
457+
set (book: OrderBook, lotSize: number, rateStepEnc: number, baseUnitInfo: UnitInfo, quoteUnitInfo: UnitInfo) {
458458
this.book = book
459459
this.lotSize = lotSize / baseUnitInfo.conventional.conversionFactor
460-
const [qFactor, bFactor] = [quoteUnitInfo.conventional.conversionFactor, baseUnitInfo.conventional.conversionFactor]
461-
this.rateStep = rateStep / RateEncodingFactor * qFactor / bFactor
460+
this.conventionalRateStep = Doc.conventionalRateStep(rateStepEnc, baseUnitInfo, quoteUnitInfo)
462461
this.baseUnit = baseUnitInfo.conventional.unit
463462
this.quoteUnit = quoteUnitInfo.conventional.unit
464463
if (!this.zoomLevel) {
@@ -588,7 +587,7 @@ export class DepthChart extends Chart {
588587

589588
// Print the x labels
590589
const xLabels = makeLabels(ctx, this.plotRegion.width(), dataExtents.x.min,
591-
dataExtents.x.max, 100, this.rateStep, '')
590+
dataExtents.x.max, 100, this.conventionalRateStep, '')
592591

593592
this.plotXLabels(xLabels, low, high, [`${this.quoteUnit}/`, this.baseUnit])
594593

@@ -611,7 +610,7 @@ export class DepthChart extends Chart {
611610
ctx.textBaseline = 'middle'
612611
ctx.fillStyle = this.theme.value
613612
const y = 0.5 * dataExtents.y.max
614-
ctx.fillText(formatLabelValue(midGap), tools.x(midGap), tools.y(y))
613+
ctx.fillText(Doc.formatFourSigFigs(midGap), tools.x(midGap), tools.y(y))
615614
ctx.font = '12px \'sans\', sans-serif'
616615
// ctx.fillText('mid-market price', tools.x(midGap), tools.y(y) + 24)
617616
ctx.fillText(`${(gapWidth / midGap * 100).toFixed(2)}% spread`,
@@ -928,7 +927,7 @@ export class CandleChart extends Chart {
928927

929928
// Apply labels.
930929
const rFactor = this.rateConversionFactor
931-
this.doYLabels(this.candleRegion, rateStep, this.market.quotesymbol, v => formatLabelValue(v / rFactor))
930+
this.doYLabels(this.candleRegion, rateStep, this.market.quotesymbol, v => Doc.formatFourSigFigs(v / rFactor))
932931
this.candleRegion.extents.x.min = this.yRegion.extents.x.max
933932
this.volumeRegion.extents.x.min = this.yRegion.extents.x.max
934933

@@ -1325,7 +1324,7 @@ function makeLabels (
13251324
unit: string,
13261325
valFmt?: (v: number) => string
13271326
): LabelSet {
1328-
valFmt = valFmt || formatLabelValue
1327+
valFmt = valFmt || Doc.formatFourSigFigs
13291328
const n = screenW / spacingGuess
13301329
const diff = max - min
13311330
if (n < 1 || diff <= 0) return { lbls: [] }
@@ -1437,17 +1436,6 @@ function clamp (v: number, min: number, max: number): number {
14371436
return v
14381437
}
14391438

1440-
/* labelSpecs is specifications for axis tick labels. */
1441-
const labelSpecs = {
1442-
minimumSignificantDigits: 4,
1443-
maximumSignificantDigits: 5
1444-
}
1445-
1446-
/* formatLabelValue formats the provided value using the labelSpecs format. */
1447-
function formatLabelValue (x: number) {
1448-
return x.toLocaleString('en-us', labelSpecs)
1449-
}
1450-
14511439
/* floatCompare compares two floats to within a tolerance of 1e-8. */
14521440
function floatCompare (a: number, b: number) {
14531441
return withinTolerance(a, b, 1e-8)

client/webserver/site/src/js/doc.ts

Lines changed: 125 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
WalletState,
66
PageElement
77
} from './registry'
8+
import { RateEncodingFactor } from './orderutil'
89

910
const parser = new window.DOMParser()
1011

@@ -27,20 +28,22 @@ const BipIDs: Record<number, string> = {
2728

2829
const BipSymbols = Object.values(BipIDs)
2930

31+
const log10RateEncodingFactor = Math.round(Math.log10(RateEncodingFactor))
32+
3033
const intFormatter = new Intl.NumberFormat((navigator.languages as string[]))
3134

32-
const threeSigFigs = new Intl.NumberFormat((navigator.languages as string[]), {
33-
minimumSignificantDigits: 3,
34-
maximumSignificantDigits: 3
35+
const fourSigFigs = new Intl.NumberFormat((navigator.languages as string[]), {
36+
minimumSignificantDigits: 4,
37+
maximumSignificantDigits: 4
3538
})
3639

37-
const fiveSigFigs = new Intl.NumberFormat((navigator.languages as string[]), {
38-
minimumSignificantDigits: 5,
39-
maximumSignificantDigits: 5
40+
const oneFractionalDigit = new Intl.NumberFormat((navigator.languages as string[]), {
41+
minimumFractionDigits: 1,
42+
maximumFractionDigits: 1
4043
})
4144

4245
/* A cache for formatters used for Doc.formatCoinValue. */
43-
const decimalFormatters = {}
46+
const decimalFormatters: Record<number, Intl.NumberFormat> = {}
4447

4548
/*
4649
* decimalFormatter gets the formatCoinValue formatter for the specified decimal
@@ -51,25 +54,25 @@ function decimalFormatter (prec: number) {
5154
}
5255

5356
/* A cache for formatters used for Doc.formatFullPrecision. */
54-
const fullPrecisionFormatters = {}
57+
const fullPrecisionFormatters: Record<number, Intl.NumberFormat> = {}
5558

5659
/*
5760
* fullPrecisionFormatter gets the formatFullPrecision formatter for the
5861
* specified decimal precision.
5962
*/
60-
function fullPrecisionFormatter (prec: number) {
61-
return formatter(fullPrecisionFormatters, prec, prec)
63+
function fullPrecisionFormatter (prec: number, locales?: string | string[]) {
64+
return formatter(fullPrecisionFormatters, prec, prec, locales)
6265
}
6366

6467
/*
6568
* formatter gets the formatter from the supplied cache if it already exists,
6669
* else creates it.
6770
*/
68-
function formatter (formatters: Record<string, Intl.NumberFormat>, min: number, max: number): Intl.NumberFormat {
71+
function formatter (formatters: Record<string, Intl.NumberFormat>, min: number, max: number, locales?: string | string[]): Intl.NumberFormat {
6972
const k = `${min}-${max}`
7073
let fmt = formatters[k]
7174
if (!fmt) {
72-
fmt = new Intl.NumberFormat((navigator.languages as string[]), {
75+
fmt = new Intl.NumberFormat(locales || navigator.languages as string[], {
7376
minimumFractionDigits: min,
7477
maximumFractionDigits: max
7578
})
@@ -259,15 +262,20 @@ export default class Doc {
259262
return decimalFormatter(prec).format(v)
260263
}
261264

262-
static formatThreeSigFigs (v: number): string {
263-
if (v >= 1000) return intFormatter.format(Math.round(v))
264-
return threeSigFigs.format(v)
265+
/*
266+
* formatRateFullPrecision formats rate to represent it exactly at rate step
267+
* precision, trimming non-effectual zeros if there are any.
268+
*/
269+
static formatRateFullPrecision (encRate: number, bui: UnitInfo, qui: UnitInfo, rateStepEnc: number) {
270+
const r = bui.conventional.conversionFactor / qui.conventional.conversionFactor
271+
const convRate = encRate * r / RateEncodingFactor
272+
const rateStepDigits = log10RateEncodingFactor - Math.floor(Math.log10(rateStepEnc)) -
273+
Math.floor(Math.log10(bui.conventional.conversionFactor) - Math.log10(qui.conventional.conversionFactor))
274+
return fullPrecisionFormatter(rateStepDigits).format(convRate)
265275
}
266276

267-
static formatFiveSigFigs (v: number, prec?: number): string {
268-
if (v >= 10000) return intFormatter.format(Math.round(v))
269-
else if (v < 1e5) return fullPrecisionFormatter(prec ?? 8 /* rate encoding factor */).format(v)
270-
return fiveSigFigs.format(v)
277+
static formatFourSigFigs (n: number): string {
278+
return formatSigFigsWithFormatters(oneFractionalDigit, fourSigFigs, n)
271279
}
272280

273281
/*
@@ -292,6 +300,11 @@ export default class Doc {
292300
return fullPrecisionFormatter(prec).format(value)
293301
}
294302

303+
static conventionalRateStep (rateStepEnc: number, baseUnitInfo: UnitInfo, quoteUnitInfo: UnitInfo) {
304+
const [qFactor, bFactor] = [quoteUnitInfo.conventional.conversionFactor, baseUnitInfo.conventional.conversionFactor]
305+
return rateStepEnc / RateEncodingFactor * (bFactor / qFactor)
306+
}
307+
295308
/*
296309
* logoPath creates a path to a png logo for the specified ticker symbol. If
297310
* the symbol is not a supported asset, the generic letter logo will be
@@ -626,3 +639,96 @@ function timeMod (t: number, dur: number) {
626639
const n = Math.floor(t / dur)
627640
return [n, t - n * dur]
628641
}
642+
643+
function formatSigFigsWithFormatters (intFormatter: Intl.NumberFormat, sigFigFormatter: Intl.NumberFormat, n: number, maxDecimals?: number, locales?: string | string[]): string {
644+
if (n >= 1000) return intFormatter.format(n)
645+
const s = sigFigFormatter.format(n)
646+
if (typeof maxDecimals !== 'number') return s
647+
const fractional = sigFigFormatter.formatToParts(n).filter((part: Intl.NumberFormatPart) => part.type === 'fraction')[0].value
648+
if (fractional.length <= maxDecimals) return s
649+
return fullPrecisionFormatter(maxDecimals, locales).format(n)
650+
}
651+
652+
if (process.env.NODE_ENV === 'development') {
653+
// Code will only appear in dev build.
654+
// https://webpack.js.org/guides/production/
655+
window.testFormatFourSigFigs = () => {
656+
const tests: [string, string, number | undefined, string][] = [
657+
['en-US', '1.234567', undefined, '1.235'], // sigFigFormatter
658+
['en-US', '1.234567', 2, '1.23'], // decimalFormatter
659+
['en-US', '1234', undefined, '1,234.0'], // oneFractionalDigit
660+
['en-US', '12', undefined, '12.00'], // sigFigFormatter
661+
['fr-FR', '123.45678', undefined, '123,5'], // oneFractionalDigit
662+
['fr-FR', '1234.5', undefined, '1 234,5'], // U+202F for thousands separator
663+
// For Arabic, https://www.saitak.com/number is useful, but seems to use
664+
// slightly different unicode points and no thousands separator. I think
665+
// the Arabic decimal separator is supposed to be more like a point, not
666+
// a comma, but Google Chrome uses U+066B (Arabic Decimal Separator),
667+
// which looks like a comma to me. ¯\_(ツ)_/¯
668+
['ar-EG', '123.45678', undefined, '١٢٣٫٥'],
669+
['ar-EG', '1234', undefined, '١٬٢٣٤٫٠'],
670+
['ar-EG', '0.12345', 3, '٠٫١٢٣']
671+
]
672+
673+
// Reproduce the NumberFormats with ONLY our desired language.
674+
for (const [code, unformatted, maxDecimals, expected] of tests) {
675+
const intFormatter = new Intl.NumberFormat(code, { // oneFractionalDigit
676+
minimumFractionDigits: 1,
677+
maximumFractionDigits: 1
678+
})
679+
const sigFigFormatter = new Intl.NumberFormat(code, {
680+
minimumSignificantDigits: 4,
681+
maximumSignificantDigits: 4
682+
})
683+
for (const k in decimalFormatters) delete decimalFormatters[k] // cleanup
684+
for (const k in fullPrecisionFormatters) delete fullPrecisionFormatters[k] // cleanup
685+
const s = formatSigFigsWithFormatters(intFormatter, sigFigFormatter, parseFloat(unformatted), maxDecimals, code)
686+
if (s !== expected) console.log(`TEST FAILED: f('${code}', ${unformatted}, ${maxDecimals}) => '${s}' != '${expected}'}`)
687+
else console.log(`✔️ f('${code}', ${unformatted}, ${maxDecimals}) => ${s} ✔️`)
688+
}
689+
}
690+
691+
window.testFormatRateFullPrecision = () => {
692+
const tests: [number, number, number, number, string][] = [
693+
// Two utxo assets with a conventional rate of 0.15. Conventional rate
694+
// step is 100 / 1e8 = 1e-6, so there should be 6 decimal digits.
695+
[1.5e7, 100, 1e8, 1e8, '0.150000'],
696+
// USDC quote -> utxo base with a rate of $10 / 1 XYZ. USDC has an
697+
// conversion factor of 1e6, so $10 encodes to 1e7, 1 XYZ encodes to 1e8,
698+
// encoded rate is 1e7 / 1e8 * 1e8 = 1e7, bFactor / qFactor is 1e2.
699+
// The conventional rate step is 200 / 1e8 * 1e2 = 2e-4, so using
700+
// rateStepDigits, we should get 4 decimal digits.
701+
[1e7, 200, 1e6, 1e8, '10.0000'],
702+
// Set a rate of 1 atom USDC for 0.01 BTC. That atomic rate will be 1 /
703+
// 1e6 = 1e-6. The encoded rate will be 1e-6 * 1e8 = 1e2. As long as our
704+
// rate step divides evenly into 100, this should work. The conventional
705+
// rate is 1e-6 / 1e-2 = 1e-4, so expect 4 decimal digits.
706+
[1e2, 100, 1e6, 1e8, '0.0001'],
707+
// DCR-ETH, expect 6 decimals.
708+
[1.5e7, 1000, 1e9, 1e8, '0.015000'],
709+
[1e6, 1000, 1e9, 1e8, '0.001000'],
710+
[1e3, 1000, 1e9, 1e8, '0.000001'],
711+
[100001000, 1000, 1e9, 1e8, '0.100001'],
712+
[1000001000, 1000, 1e9, 1e8, '1.000001'],
713+
// DCR-USDC, expect 3 decimals.
714+
[1.5e7, 1000, 1e6, 1e8, '15.000'],
715+
[1e6, 1000, 1e6, 1e8, '1.000'],
716+
[1e3, 1000, 1e6, 1e8, '0.001'],
717+
[101000, 1000, 1e6, 1e8, '0.101'],
718+
[1001000, 1000, 1e6, 1e8, '1.001'],
719+
// UTXO assets but with a rate step that's not a perfect power of 10.
720+
// For a rate step of 500, a min rate would be e.g. rate step = 500.
721+
// 5e2 / 1e8 = 5e-6 = 0.000005
722+
[5e2, 500, 1e8, 1e8, '0.000005']
723+
]
724+
725+
for (const [encRate, rateStep, qFactor, bFactor, expEncoding] of tests) {
726+
for (const k in fullPrecisionFormatters) delete fullPrecisionFormatters[k] // cleanup
727+
const bui = { conventional: { conversionFactor: bFactor } } as any as UnitInfo
728+
const qui = { conventional: { conversionFactor: qFactor } } as any as UnitInfo
729+
const enc = Doc.formatRateFullPrecision(encRate, bui, qui, rateStep)
730+
if (enc !== expEncoding) console.log(`TEST FAILED: f(${encRate}, ${bFactor}, ${qFactor}, ${rateStep}) => ${enc} != ${expEncoding}`)
731+
else console.log(`✔️ f(${encRate}, ${bFactor}, ${qFactor}, ${rateStep}) => ${enc} ✔️`)
732+
}
733+
}
734+
}

0 commit comments

Comments
 (0)