diff --git a/components/src/FormatControls/FormatControls.test.tsx b/components/src/FormatControls/FormatControls.test.tsx
index 7b582c15..6b5f6236 100644
--- a/components/src/FormatControls/FormatControls.test.tsx
+++ b/components/src/FormatControls/FormatControls.test.tsx
@@ -11,7 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { FormatOptions } from '../model';
@@ -38,6 +38,10 @@ describe('FormatControls', () => {
return screen.getByRole('checkbox', { name: 'Short values' });
};
+ const getCustomLabelInput = (): HTMLElement => {
+ return screen.getByRole('textbox', { name: 'custom unit label' });
+ };
+
it('can change the unit by clicking', () => {
const onChange = vi.fn();
renderFormatControls({ unit: 'minutes' }, onChange);
@@ -59,6 +63,7 @@ describe('FormatControls', () => {
renderFormatControls({ unit: 'bytes' }, onChange);
const unitSelector = getUnitSelector();
+ // Tab: Short values → Unit (Custom label present for bytes)
userEvent.tab();
userEvent.tab();
expect(unitSelector).toHaveFocus();
@@ -76,6 +81,23 @@ describe('FormatControls', () => {
});
});
+ it('shows custom label for data-size units (bytes)', () => {
+ renderFormatControls({ unit: 'bytes' });
+ expect(screen.getByRole('textbox', { name: 'custom unit label' })).toBeInTheDocument();
+ });
+
+ it('preserves customLabel when switching to bytes', () => {
+ const onChange = vi.fn();
+ renderFormatControls({ unit: 'ops/sec', customLabel: 'pnr/mn' }, onChange);
+
+ userEvent.click(getUnitSelector());
+ userEvent.click(screen.getByRole('option', { name: 'Bytes (IEC)' }));
+ expect(onChange).toHaveBeenCalledWith({
+ unit: 'bytes',
+ customLabel: 'pnr/mn',
+ });
+ });
+
it('can change the decimal places by clicking', () => {
const onChange = vi.fn();
renderFormatControls({ unit: 'decimal', decimalPlaces: 0, shortValues: true }, onChange);
@@ -98,8 +120,8 @@ describe('FormatControls', () => {
renderFormatControls({ unit: 'percent' }, onChange);
const decimalPlacesSelector = getDecimalPlacesSelector();
- userEvent.tab();
- userEvent.tab();
+ // Focus Decimals directly (tab order includes Custom label after Unit).
+ decimalPlacesSelector.focus();
expect(decimalPlacesSelector).toHaveFocus();
userEvent.clear(decimalPlacesSelector);
@@ -115,6 +137,40 @@ describe('FormatControls', () => {
});
});
+ it('can set a custom label (spaces allowed in raw onChange)', () => {
+ const onChange = vi.fn();
+ renderFormatControls({ unit: 'ops/sec' }, onChange);
+
+ const input = getCustomLabelInput();
+ // Controlled field + mock onChange does not re-render; fire a full value change.
+ fireEvent.change(input, { target: { value: 'pnr / mn' } });
+ expect(onChange).toHaveBeenCalledWith({
+ unit: 'ops/sec',
+ customLabel: 'pnr / mn',
+ });
+ });
+
+ it('clears customLabel when the field is emptied', () => {
+ const onChange = vi.fn();
+ renderFormatControls({ unit: 'ops/sec', customLabel: 'pnr/mn' }, onChange);
+
+ const input = getCustomLabelInput();
+ fireEvent.change(input, { target: { value: '' } });
+ expect(onChange).toHaveBeenCalledWith({ unit: 'ops/sec' });
+ });
+
+ it('preserves customLabel when changing unit', () => {
+ const onChange = vi.fn();
+ renderFormatControls({ unit: 'ops/sec', customLabel: 'pnr/mn' }, onChange);
+
+ userEvent.click(getUnitSelector());
+ userEvent.click(screen.getByRole('option', { name: 'Decimal' }));
+ expect(onChange).toHaveBeenCalledWith({
+ unit: 'decimal',
+ customLabel: 'pnr/mn',
+ });
+ });
+
it('can change shortValues by clicking', () => {
const onChange = vi.fn();
renderFormatControls({ unit: 'decimal', decimalPlaces: 3, shortValues: true }, onChange);
diff --git a/components/src/FormatControls/FormatControls.tsx b/components/src/FormatControls/FormatControls.tsx
index 68224413..1de71c9f 100644
--- a/components/src/FormatControls/FormatControls.tsx
+++ b/components/src/FormatControls/FormatControls.tsx
@@ -11,11 +11,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import type { SwitchProps } from '@mui/material';
-import { Switch } from '@mui/material';
+import { Switch, TextField } from '@mui/material';
import type { ReactElement } from 'react';
import type { FormatOptions } from '../model';
-import { isUnitWithDecimalPlaces, isUnitWithShortValues, shouldShortenValues } from '../model';
+import { isUnitWithDecimalPlaces, isUnitWithShortValues, shouldShortenValues, supportsCustomLabel } from '../model';
import { OptionsEditorControl } from '../OptionsEditorLayout';
import { SettingsAutocomplete } from '../SettingsAutocomplete';
import { UnitSelector } from './UnitSelector';
@@ -46,9 +46,18 @@ export function FormatControls({ value, onChange, disabled = false }: FormatCont
const hasShortValues = isUnitWithShortValues(value);
const handleUnitChange = (newValue: FormatOptions | undefined): void => {
- onChange(newValue || { unit: 'decimal' }); // Fallback to 'decimal' if undefined
+ const next = newValue || { unit: 'decimal' };
+ const customLabel = value.customLabel?.trim();
+ // Keep label only when the new unit supports customLabel (whitelist).
+ if (customLabel && supportsCustomLabel(next.unit)) {
+ onChange({ ...next, customLabel: value.customLabel });
+ return;
+ }
+ onChange(next);
};
+ const showCustomLabel = supportsCustomLabel(value.unit);
+
const handleDecimalPlacesChange = ({
decimalPlaces,
}: {
@@ -73,6 +82,16 @@ export function FormatControls({ value, onChange, disabled = false }: FormatCont
}
};
+ const handleCustomLabelChange = (raw: string): void => {
+ // Keep raw input while typing (spaces allowed). Trim only when clearing / display.
+ if (raw === '') {
+ const { customLabel: _removed, ...rest } = value;
+ onChange(rest as FormatOptions);
+ return;
+ }
+ onChange({ ...value, customLabel: raw });
+ };
+
return (
<>
}
/>
+ {showCustomLabel && (
+ handleCustomLabelChange(e.target.value)}
+ placeholder="Optional display override (e.g. pnr/mn)"
+ disabled={disabled}
+ inputProps={{ 'aria-label': 'custom unit label', maxLength: 32 }}
+ />
+ }
+ />
+ )}
{
if (newValue === null) {
onChange(undefined);
- } else {
- onChange({ unit: newValue.id } as FormatOptions);
+ return;
}
+ onChange({ unit: newValue.id } as FormatOptions);
};
return (
diff --git a/components/src/model/custom.test.ts b/components/src/model/custom.test.ts
new file mode 100644
index 00000000..080f5856
--- /dev/null
+++ b/components/src/model/custom.test.ts
@@ -0,0 +1,156 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { describe, expect, it } from 'vitest';
+
+import { applyCustomLabel, supportsCustomLabel } from './custom';
+import { formatValue } from './units';
+
+describe('supportsCustomLabel', () => {
+ it('allows decimal, time, percent, throughput, data sizes, currency', () => {
+ expect(supportsCustomLabel('decimal')).toBe(true);
+ expect(supportsCustomLabel('ops/sec')).toBe(true);
+ expect(supportsCustomLabel('milliseconds')).toBe(true);
+ expect(supportsCustomLabel('bytes')).toBe(true);
+ expect(supportsCustomLabel('bytes/sec')).toBe(true);
+ expect(supportsCustomLabel('usd')).toBe(true);
+ expect(supportsCustomLabel(undefined)).toBe(true);
+ });
+
+ it('rejects date/time identity formats', () => {
+ expect(supportsCustomLabel('datetime-iso')).toBe(false);
+ expect(supportsCustomLabel('unix-timestamp')).toBe(false);
+ });
+});
+
+describe('applyCustomLabel', () => {
+ it('replaces spaced unit suffix', () => {
+ expect(applyCustomLabel('1.5K ops/sec', 'pnr/mn', 'ops/sec')).toBe('1.5K pnr/mn');
+ });
+
+ it('replaces percent suffix without leaving %', () => {
+ expect(applyCustomLabel('12%', 'load', 'percent')).toBe('12 load');
+ });
+
+ it('replaces celsius suffix', () => {
+ expect(applyCustomLabel('11°C', 'room', 'celsius')).toBe('11 room');
+ });
+
+ it('returns unchanged when label empty', () => {
+ expect(applyCustomLabel('42 ops/sec', '', 'ops/sec')).toBe('42 ops/sec');
+ });
+
+ it('replaces Intl time narrow suffixes (ms, s)', () => {
+ expect(applyCustomLabel('500ms', 'latency', 'milliseconds')).toBe('500 latency');
+ expect(applyCustomLabel('1.5s', 'wait', 'seconds')).toBe('1.5 wait');
+ });
+
+ it('replaces Intl time long suffixes (month)', () => {
+ expect(applyCustomLabel('1 month', 'period', 'months')).toBe('1 period');
+ expect(applyCustomLabel('2 months', 'period', 'months')).toBe('2 period');
+ });
+
+ it('time zero sentinel', () => {
+ expect(applyCustomLabel('0s', 'period', 'months')).toBe('0 period');
+ });
+
+ it('data size: keeps IEC scale, replaces base unit (not glued gigaprout)', () => {
+ expect(applyCustomLabel('1.46 KiB', 'prout', 'bytes')).toBe('1.46 Ki prout');
+ expect(applyCustomLabel('1.4 GiB', 'prout', 'bytes')).toBe('1.4 Gi prout');
+ expect(applyCustomLabel('500 bytes', 'prout', 'bytes')).toBe('500 prout');
+ });
+
+ it('data size rate: keeps scale and /s', () => {
+ expect(applyCustomLabel('1.46 KiB/s', 'wire', 'bytes/sec')).toBe('1.46 Ki wire/s');
+ expect(applyCustomLabel('1.5 KB/s', 'wire', 'decbytes/sec')).toBe('1.5 K wire/s');
+ expect(applyCustomLabel('500 bytes/sec', 'wire', 'bytes/sec')).toBe('500 wire/sec');
+ });
+
+ it('currency: amount + custom label', () => {
+ expect(applyCustomLabel('$1,500', 'credits', 'usd')).toBe('1,500 credits');
+ expect(applyCustomLabel('€12.3', 'credits', 'eur')).toBe('12.3 credits');
+ });
+
+ it('dates unchanged', () => {
+ const iso = '2023-11-14T22:13:20.000Z';
+ expect(applyCustomLabel(iso, 'ignored', 'datetime-iso')).toBe(iso);
+ });
+});
+
+describe('formatValue with customLabel', () => {
+ it('keeps unit key ops/sec and shows custom label', () => {
+ expect(formatValue(1500, { unit: 'ops/sec', shortValues: true, customLabel: 'pnr/mn' })).toBe('1.5K pnr/mn');
+ });
+
+ it('works with decimal base', () => {
+ expect(formatValue(12.34, { unit: 'decimal', decimalPlaces: 1, customLabel: 'pax/mn' })).toBe('12.3 pax/mn');
+ });
+
+ it('percent custom label replaces %', () => {
+ const out = formatValue(0.5, { unit: 'percent', customLabel: 'util' });
+ expect(out).not.toContain('%');
+ expect(out).toContain('util');
+ });
+
+ it('without customLabel is unchanged', () => {
+ expect(formatValue(10, { unit: 'ops/sec' })).toBe('10 ops/sec');
+ });
+
+ it('time months: no residual month/ms unit text', () => {
+ const out = formatValue(1, { unit: 'months', customLabel: 'billing' });
+ expect(out.toLowerCase()).not.toMatch(/month|ms\b|week|day/);
+ expect(out).toContain('billing');
+ });
+
+ it('time milliseconds: no residual ms', () => {
+ const out = formatValue(500, { unit: 'milliseconds', customLabel: 'latency' });
+ expect(out.toLowerCase()).not.toContain('ms');
+ expect(out).toContain('latency');
+ });
+
+ it('bytes: scale kept, base replaced', () => {
+ const out = formatValue(1_500_000_000, { unit: 'bytes', shortValues: true, customLabel: 'prout' });
+ expect(out).toMatch(/Gi prout$/);
+ expect(out.toLowerCase()).not.toContain('gib');
+ expect(out).not.toMatch(/gigaprout/i);
+ });
+
+ it('bytes/sec: scale + rate kept', () => {
+ const out = formatValue(1_500_000, { unit: 'bytes/sec', shortValues: true, customLabel: 'wire' });
+ expect(out).toMatch(/Mi wire\/s$/);
+ });
+
+ it('usd custom label', () => {
+ const out = formatValue(1500, { unit: 'usd', customLabel: 'credits' });
+ expect(out).toContain('credits');
+ expect(out).not.toContain('$');
+ });
+
+ it('datetime-iso ignores customLabel', () => {
+ const plain = formatValue(1_700_000_000, { unit: 'datetime-iso' });
+ const labeled = formatValue(1_700_000_000, { unit: 'datetime-iso', customLabel: 'when' });
+ expect(labeled).toBe(plain);
+ });
+
+ // Time ticks rescale via Intl (month / week / day / ms); customLabel must fully replace each.
+ it('months customLabel: every tick scale is fully overridden (no mixed suffixes)', () => {
+ const label = 'custom';
+ const fmt = { unit: 'months' as const, customLabel: label };
+ const values = [1, 0.5, 0.1, 0.01, 0];
+ for (const v of values) {
+ const out = formatValue(v, fmt);
+ expect(out, `value=${v}`).toMatch(new RegExp(`${label}$`));
+ expect(out.toLowerCase(), `value=${v}`).not.toMatch(/\b(month|months|week|weeks|day|days|hour|ms|s)\b/);
+ }
+ });
+});
diff --git a/components/src/model/custom.ts b/components/src/model/custom.ts
new file mode 100644
index 00000000..115217c7
--- /dev/null
+++ b/components/src/model/custom.ts
@@ -0,0 +1,235 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * Optional display override on standard formats.
+ * `unit` remains the stable key (multi-axis, maps, UI config);
+ * `customLabel` only changes the text shown after the scaled quantity.
+ *
+ * Data sizes (bytes/bits): keep SI/IEC scale (Ki, Mi, K, M, …) and replace the
+ * base unit — e.g. "1.46 KiB" + "prout" → "1.46 Ki prout" (not "gigaprout").
+ * Dates stay untouched (structured timestamps).
+ */
+
+export type WithCustomLabel = {
+ customLabel?: string;
+};
+
+/** Units where customLabel is not applied (unstructured / identity formats). */
+const CUSTOM_LABEL_EXCLUDED_UNITS = new Set([
+ 'datetime-iso',
+ 'datetime-us',
+ 'datetime-local',
+ 'date-iso',
+ 'date-us',
+ 'date-local',
+ 'time-local',
+ 'time-iso',
+ 'time-us',
+ 'relative-time',
+ 'unix-timestamp',
+ 'unix-timestamp-ms',
+]);
+
+const DATA_SIZE_UNITS = new Set([
+ 'bytes',
+ 'decbytes',
+ 'bits',
+ 'decbits',
+ 'bytes/sec',
+ 'decbytes/sec',
+ 'bits/sec',
+ 'decbits/sec',
+]);
+
+const CURRENCY_UNITS = new Set([
+ 'usd',
+ 'eur',
+ 'gbp',
+ 'jpy',
+ 'cny',
+ 'cad',
+ 'aud',
+ 'chf',
+ 'hkd',
+ 'sgd',
+ 'sek',
+ 'krw',
+ 'nok',
+ 'nzd',
+ 'inr',
+ 'mxn',
+ 'twd',
+ 'zar',
+ 'brl',
+ 'dkk',
+ 'pln',
+ 'thb',
+ 'ils',
+ 'czk',
+ 'clp',
+ 'php',
+ 'aed',
+ 'cop',
+ 'sar',
+ 'myr',
+ 'ron',
+ 'afn',
+]);
+
+/**
+ * Whether FormatControls / formatValue should honor customLabel for this unit key.
+ */
+export function supportsCustomLabel(unit?: string): boolean {
+ if (!unit) {
+ return true;
+ }
+ return !CUSTOM_LABEL_EXCLUDED_UNITS.has(unit);
+}
+
+/** Sign + number (grouped / scientific). */
+const LEADING_NUMBER = /^[+-]?(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?/;
+
+/**
+ * If customLabel is set and the unit is supported, replace the standard unit text
+ * with the display label. Dates leave `formatted` unchanged.
+ */
+export function applyCustomLabel(formatted: string, customLabel?: string, unit?: string): string {
+ const label = customLabel?.trim();
+ if (!label) {
+ return formatted;
+ }
+ if (!supportsCustomLabel(unit)) {
+ return formatted;
+ }
+
+ if (unit && DATA_SIZE_UNITS.has(unit)) {
+ return applyDataSizeCustomLabel(formatted, label);
+ }
+
+ if (unit && CURRENCY_UNITS.has(unit)) {
+ return applyCurrencyCustomLabel(formatted, label);
+ }
+
+ // formatTime zero sentinel
+ if (formatted === '0s') {
+ return `0 ${label}`;
+ }
+
+ // Percent: "12.3%" → "12.3 load"
+ if (unit === 'percent' || unit === 'percent-decimal') {
+ if (formatted.endsWith('%')) {
+ return `${formatted.slice(0, -1).trimEnd()} ${label}`;
+ }
+ }
+
+ // Temperature: "11°C" / "52°F"
+ if (unit === 'celsius' && formatted.endsWith('°C')) {
+ return `${formatted.slice(0, -2).trimEnd()} ${label}`;
+ }
+ if (unit === 'fahrenheit' && formatted.endsWith('°F')) {
+ return `${formatted.slice(0, -2).trimEnd()} ${label}`;
+ }
+
+ // Exact unit-key suffix: "10 ops/sec", "1.5K ops/sec"
+ if (unit && formatted.endsWith(` ${unit}`)) {
+ return `${formatted.slice(0, -(unit.length + 1))} ${label}`;
+ }
+
+ // Time / decimal: keep leading quantity only (drops "ms", " month", …)
+ const qty = leadingQuantity(formatted);
+ if (qty !== null) {
+ return `${qty} ${label}`;
+ }
+
+ return `${formatted} ${label}`;
+}
+
+/**
+ * Bytes/bits (+ optional /s|/sec): keep scale (Ki, Mi, K, M, …), replace base unit.
+ * "1.46 KiB" + prout → "1.46 Ki prout"
+ * "1.5 KB/s" + wire → "1.5 K wire/s"
+ * "500 bytes" + prout → "500 prout"
+ */
+function applyDataSizeCustomLabel(formatted: string, label: string): string {
+ let rate = '';
+ let body = formatted.trim();
+ const rateM = body.match(/(\/s(?:ec)?)$/i);
+ if (rateM?.[1]) {
+ rate = rateM[1];
+ body = body.slice(0, -rate.length).trimEnd();
+ }
+
+ // Long form: "500 bytes", "1,500 bits"
+ const long = body.match(/^(.+?)\s+(bytes?|bits?)$/i);
+ if (long?.[1]) {
+ return `${long[1].trim()} ${label}${rate}`;
+ }
+
+ // Short form: "1.46 KiB", "1.5 KB", "1.46 Kib", "1.5 Kb" (optional space before scale)
+ const short = body.match(/^(.+?)\s*([KMGTPE]i?)\s*([Bb])$/);
+ if (short?.[1] && short[2]) {
+ return `${short[1].trim()} ${short[2]} ${label}${rate}`;
+ }
+
+ const qty = leadingQuantity(body);
+ if (qty !== null) {
+ return `${qty} ${label}${rate}`;
+ }
+ return `${formatted} ${label}`;
+}
+
+/**
+ * Currency: drop symbol/code, keep amount + custom label.
+ * "$1,500" + credits → "1,500 credits"
+ */
+function applyCurrencyCustomLabel(formatted: string, label: string): string {
+ // Strip common leading/trailing currency symbols and ISO codes
+ let body = formatted.trim();
+ body = body.replace(/^[^\d+-]+/, ''); // leading $ € …
+ body = body.replace(/\s*[A-Z]{3}$/, ''); // trailing USD
+ body = body.trim();
+ const qty = leadingQuantity(body);
+ if (qty !== null) {
+ // Keep grouping from the rest of the amount if present after qty
+ const rest = body.slice(qty.length);
+ // If rest is only grouping leftovers empty, use qty; else full body without symbols
+ if (rest === '' || /^[\d,.]+$/.test(body.replace(LEADING_NUMBER, ''))) {
+ return `${body} ${label}`;
+ }
+ return `${body} ${label}`;
+ }
+ return `${body} ${label}`;
+}
+
+/**
+ * Numeric prefix of a formatted value, keeping SI compact (1.5K)
+ * but not unit letters glued to the number (500ms → 500, not 500m).
+ */
+function leadingQuantity(formatted: string): string | null {
+ const s = formatted.trimStart();
+ const num = s.match(LEADING_NUMBER);
+ if (!num?.[0]) {
+ return null;
+ }
+ const head = num[0];
+ const rest = s.slice(head.length);
+
+ // Compact decimal suffix K/M/B/T only when not starting a longer unit word (ms, min, …).
+ const compact = rest.match(/^([KMBT])(?=\s|$)/i);
+ if (compact) {
+ return head + compact[1];
+ }
+
+ return head;
+}
diff --git a/components/src/model/index.ts b/components/src/model/index.ts
index 11e524e2..248acbe4 100644
--- a/components/src/model/index.ts
+++ b/components/src/model/index.ts
@@ -25,6 +25,7 @@ export * from './percent';
export * from './temperature';
export * from './decimal';
export * from './throughput';
+export * from './custom';
export * from './formatterCache';
export * from './units';
export * from './utils';
diff --git a/components/src/model/units.ts b/components/src/model/units.ts
index 59f1717f..0b984492 100644
--- a/components/src/model/units.ts
+++ b/components/src/model/units.ts
@@ -17,6 +17,8 @@ import type { BytesFormatOptions } from './bytes';
import { formatBytes, BYTES_GROUP_CONFIG, BYTES_UNIT_CONFIG } from './bytes';
import type { CurrencyFormatOptions } from './currency';
import { formatCurrency, CURRENCY_GROUP_CONFIG, CURRENCY_UNIT_CONFIG } from './currency';
+import type { WithCustomLabel } from './custom';
+import { applyCustomLabel, supportsCustomLabel } from './custom';
import type { DateFormatOptions } from './date';
import { formatDate, DATE_GROUP_CONFIG, DATE_UNIT_CONFIG } from './date';
import type { DecimalFormatOptions } from './decimal';
@@ -62,7 +64,8 @@ export const UNIT_CONFIG = {
...DATE_UNIT_CONFIG,
} as const;
-export type FormatOptions =
+/** Standard unit options plus optional display override (customLabel). */
+export type FormatOptions = (
| TimeFormatOptions
| PercentFormatOptions
| DecimalFormatOptions
@@ -71,7 +74,9 @@ export type FormatOptions =
| ThroughputFormatOptions
| CurrencyFormatOptions
| TemperatureFormatOptions
- | DateFormatOptions;
+ | DateFormatOptions
+) &
+ WithCustomLabel;
type HasDecimalPlaces = UnitOpt extends { decimalPlaces?: number } ? UnitOpt : never;
type HasShortValues = UnitOpt extends { shortValues?: boolean } ? UnitOpt : never;
@@ -81,49 +86,42 @@ export function formatValue(value: number, formatOptions?: FormatOptions): strin
return value.toString();
}
+ let formatted: string;
if (isBytesUnit(formatOptions)) {
- return formatBytes(value, formatOptions);
+ formatted = formatBytes(value, formatOptions);
+ } else if (isBitsUnit(formatOptions)) {
+ formatted = formatBits(value, formatOptions);
+ } else if (isDecimalUnit(formatOptions)) {
+ formatted = formatDecimal(value, formatOptions);
+ } else if (isPercentUnit(formatOptions)) {
+ formatted = formatPercent(value, formatOptions);
+ } else if (isTimeUnit(formatOptions)) {
+ formatted = formatTime(value, formatOptions);
+ } else if (isThroughputUnit(formatOptions)) {
+ formatted = formatThroughput(value, formatOptions);
+ } else if (isCurrencyUnit(formatOptions)) {
+ formatted = formatCurrency(value, formatOptions);
+ } else if (isDateUnit(formatOptions)) {
+ formatted = formatDate(value, formatOptions);
+ } else if (isTemperatureUnit(formatOptions)) {
+ formatted = formatTemperature(value, formatOptions);
+ } else {
+ const exhaustive: never = formatOptions;
+ throw new Error(`Unknown unit options ${exhaustive}`);
}
- if (isBitsUnit(formatOptions)) {
- return formatBits(value, formatOptions);
- }
-
- if (isDecimalUnit(formatOptions)) {
- return formatDecimal(value, formatOptions);
- }
-
- if (isPercentUnit(formatOptions)) {
- return formatPercent(value, formatOptions);
- }
-
- if (isTimeUnit(formatOptions)) {
- return formatTime(value, formatOptions);
- }
-
- if (isThroughputUnit(formatOptions)) {
- return formatThroughput(value, formatOptions);
- }
-
- if (isCurrencyUnit(formatOptions)) {
- return formatCurrency(value, formatOptions);
- }
-
- if (isDateUnit(formatOptions)) {
- return formatDate(value, formatOptions);
- }
-
- if (isTemperatureUnit(formatOptions)) {
- return formatTemperature(value, formatOptions);
- }
-
- const exhaustive: never = formatOptions;
- throw new Error(`Unknown unit options ${exhaustive}`);
+ return applyCustomLabel(formatted, formatOptions.customLabel, formatOptions.unit);
}
export function getUnitConfig(formatOptions: FormatOptions): UnitConfig {
const unit = formatOptions.unit ?? 'decimal';
- return UNIT_CONFIG[unit];
+ const config = UNIT_CONFIG[unit];
+ const customLabel = formatOptions.customLabel?.trim();
+ // Only override display name when customLabel is supported for this unit key.
+ if (customLabel && supportsCustomLabel(unit)) {
+ return { ...config, label: customLabel };
+ }
+ return config;
}
export function getUnitGroup(formatOptions: FormatOptions): UnitGroup {
@@ -136,23 +134,23 @@ export function getUnitGroupConfig(formatOptions: FormatOptions): UnitGroupConfi
}
// Type guards
-export function isTimeUnit(formatOptions: FormatOptions): formatOptions is TimeFormatOptions {
+export function isTimeUnit(formatOptions: FormatOptions): formatOptions is TimeFormatOptions & WithCustomLabel {
return getUnitGroup(formatOptions) === 'Time';
}
-export function isPercentUnit(formatOptions: FormatOptions): formatOptions is PercentFormatOptions {
+export function isPercentUnit(formatOptions: FormatOptions): formatOptions is PercentFormatOptions & WithCustomLabel {
return getUnitGroup(formatOptions) === 'Percent';
}
-export function isDecimalUnit(formatOptions: FormatOptions): formatOptions is DecimalFormatOptions {
+export function isDecimalUnit(formatOptions: FormatOptions): formatOptions is DecimalFormatOptions & WithCustomLabel {
return getUnitGroup(formatOptions) === 'Decimal';
}
-export function isBytesUnit(formatOptions: FormatOptions): formatOptions is BytesFormatOptions {
+export function isBytesUnit(formatOptions: FormatOptions): formatOptions is BytesFormatOptions & WithCustomLabel {
return getUnitGroup(formatOptions) === 'Bytes';
}
-export function isBitsUnit(formatOptions: FormatOptions): formatOptions is BitsFormatOptions {
+export function isBitsUnit(formatOptions: FormatOptions): formatOptions is BitsFormatOptions & WithCustomLabel {
return getUnitGroup(formatOptions) === 'Bits';
}
@@ -170,18 +168,22 @@ export function isUnitWithShortValues(formatOptions: FormatOptions): formatOptio
return !!groupConfig.shortValues;
}
-export function isThroughputUnit(formatOptions: FormatOptions): formatOptions is ThroughputFormatOptions {
+export function isThroughputUnit(
+ formatOptions: FormatOptions,
+): formatOptions is ThroughputFormatOptions & WithCustomLabel {
return getUnitGroup(formatOptions) === 'Throughput';
}
-export function isCurrencyUnit(formatOptions: FormatOptions): formatOptions is CurrencyFormatOptions {
+export function isCurrencyUnit(formatOptions: FormatOptions): formatOptions is CurrencyFormatOptions & WithCustomLabel {
return getUnitGroup(formatOptions) === 'Currency';
}
-export function isDateUnit(formatOptions: FormatOptions): formatOptions is DateFormatOptions {
+export function isDateUnit(formatOptions: FormatOptions): formatOptions is DateFormatOptions & WithCustomLabel {
return getUnitGroup(formatOptions) === 'Date';
}
-export function isTemperatureUnit(formatOptions: FormatOptions): formatOptions is TemperatureFormatOptions {
+export function isTemperatureUnit(
+ formatOptions: FormatOptions,
+): formatOptions is TemperatureFormatOptions & WithCustomLabel {
return getUnitGroup(formatOptions) === 'Temperature';
}
diff --git a/cue-test/common/format.cue b/cue-test/common/format.cue
index cac80338..313c50d6 100644
--- a/cue-test/common/format.cue
+++ b/cue-test/common/format.cue
@@ -14,6 +14,15 @@
package common
myFormat: #format & {
+ unit: "decimal"
decimalPlaces: 0
shortValues: false
}
+
+// Standard unit key + org display label (multi-axis still keys on unit).
+myFormatWithCustomLabel: #format & {
+ unit: "ops/sec"
+ customLabel: "pnr/mn"
+ shortValues: true
+ decimalPlaces: 1
+}
diff --git a/cue/common/format.cue b/cue/common/format.cue
index 4c0f13bc..561f59a2 100644
--- a/cue/common/format.cue
+++ b/cue/common/format.cue
@@ -13,19 +13,27 @@
package common
-#format: #simpleFormat | #floatFormat | #shortenableFormat
+import "strings"
+
+// Shared optional fields once; unit is refined by each format branch.
+#format: {
+ unit?: string
+ // Optional display override (axis / legend / tooltip). unit stays the stable key.
+ customLabel?: strings.MinRunes(1)
+ #simpleFormat | #floatFormat | #shortenableFormat
+}
#simpleFormat: {
- unit?: #dateFormat.unit
+ unit?: #dateFormat.unit
}
#floatFormat: {
- unit?: #timeFormat.unit | #percentFormat.unit | #currencyFormat.unit | #temperatureFormat.unit
- decimalPlaces?: number
+ unit?: #timeFormat.unit | #percentFormat.unit | #currencyFormat.unit | #temperatureFormat.unit
+ decimalPlaces?: number
}
#shortenableFormat: {
- unit?: #decimalFormat.unit | #bitsFormat.unit | #bytesFormat.unit | #throughputFormat.unit
+ unit?: #decimalFormat.unit | #bitsFormat.unit | #bytesFormat.unit | #throughputFormat.unit
decimalPlaces?: number
shortValues?: bool
}