672 lines
19 KiB
JavaScript
672 lines
19 KiB
JavaScript
/**
|
|
* Custom Dashboards — user + agent editable live chart boards.
|
|
*
|
|
* Storage shape (settings.customDashboards):
|
|
* {
|
|
* id, name, description,
|
|
* tiles: [{ id, chart, mode?, title? }],
|
|
* createdAt, updatedAt
|
|
* }
|
|
*/
|
|
import { drawChart, seriesColor } from './charts.js'
|
|
import { normalizeChartMode, CHART_MODE_LABEL } from '../shared/chart-types.js'
|
|
|
|
/**
|
|
* @typedef {{
|
|
* id: string,
|
|
* chart: string,
|
|
* mode?: string,
|
|
* title?: string,
|
|
* }} DashTile
|
|
*
|
|
* @typedef {{
|
|
* id: string,
|
|
* name: string,
|
|
* description?: string,
|
|
* tiles: DashTile[],
|
|
* createdAt: number,
|
|
* updatedAt: number,
|
|
* }} CustomDashboard
|
|
*/
|
|
|
|
function uid(prefix = 'd') {
|
|
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
|
}
|
|
|
|
/**
|
|
* @param {Partial<CustomDashboard> & { name?: string }} partial
|
|
* @returns {CustomDashboard}
|
|
*/
|
|
export function createDashboardRecord(partial = {}) {
|
|
const now = Date.now()
|
|
return {
|
|
id: partial.id || uid('dash'),
|
|
name: String(partial.name || 'New dashboard').slice(0, 80),
|
|
description: String(partial.description || '').slice(0, 400),
|
|
tiles: Array.isArray(partial.tiles)
|
|
? partial.tiles.map(normalizeTile).filter(Boolean)
|
|
: [],
|
|
createdAt: partial.createdAt || now,
|
|
updatedAt: now,
|
|
}
|
|
}
|
|
|
|
/** @param {any} t @returns {DashTile|null} */
|
|
function normalizeTile(t) {
|
|
if (!t) return null
|
|
if (typeof t === 'string') {
|
|
const chart = t.trim()
|
|
if (!chart) return null
|
|
return { id: uid('tile'), chart, mode: 'area' }
|
|
}
|
|
const chart = String(t.chart || t.chartId || '').trim()
|
|
if (!chart) return null
|
|
return {
|
|
id: String(t.tileId || t.uid || uid('tile')),
|
|
chart,
|
|
mode: t.mode ? normalizeChartMode(t.mode) : undefined,
|
|
title: t.title ? String(t.title).slice(0, 120) : undefined,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pure store helpers (no DOM).
|
|
* @param {CustomDashboard[]} list
|
|
* @param {string|null|undefined} activeId
|
|
*/
|
|
export function createDashboardStore(list = [], activeId = null) {
|
|
/** @type {CustomDashboard[]} */
|
|
let dashboards = Array.isArray(list)
|
|
? list.map((d) => createDashboardRecord(d))
|
|
: []
|
|
let active = activeId && dashboards.some((d) => d.id === activeId) ? activeId : dashboards[0]?.id || null
|
|
|
|
function all() {
|
|
return dashboards.map((d) => ({ ...d, tiles: d.tiles.map((t) => ({ ...t })) }))
|
|
}
|
|
|
|
function get(id) {
|
|
return dashboards.find((d) => d.id === id) || null
|
|
}
|
|
|
|
function getActive() {
|
|
return (active && get(active)) || dashboards[0] || null
|
|
}
|
|
|
|
function setActive(id) {
|
|
if (!id || !get(id)) return getActive()
|
|
active = id
|
|
return get(id)
|
|
}
|
|
|
|
function upsert(partial) {
|
|
const existing = partial.id ? get(partial.id) : null
|
|
if (existing) {
|
|
existing.name = partial.name != null ? String(partial.name).slice(0, 80) : existing.name
|
|
existing.description =
|
|
partial.description != null
|
|
? String(partial.description).slice(0, 400)
|
|
: existing.description
|
|
if (Array.isArray(partial.tiles)) {
|
|
existing.tiles = partial.tiles.map(normalizeTile).filter(Boolean)
|
|
}
|
|
existing.updatedAt = Date.now()
|
|
return { ...existing, tiles: existing.tiles.map((t) => ({ ...t })) }
|
|
}
|
|
const created = createDashboardRecord(partial)
|
|
dashboards.push(created)
|
|
if (!active) active = created.id
|
|
return created
|
|
}
|
|
|
|
function remove(id) {
|
|
const i = dashboards.findIndex((d) => d.id === id)
|
|
if (i < 0) return false
|
|
dashboards.splice(i, 1)
|
|
if (active === id) active = dashboards[0]?.id || null
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* @param {string} dashId
|
|
* @param {Array<string|DashTile>} charts
|
|
* @param {{ mode?: string, replace?: boolean }} [opts]
|
|
*/
|
|
function addTiles(dashId, charts, opts = {}) {
|
|
const d = get(dashId)
|
|
if (!d) return { ok: false, error: 'dashboard not found' }
|
|
const incoming = (Array.isArray(charts) ? charts : [charts]).map((c) => {
|
|
if (typeof c === 'string') return normalizeTile({ chart: c, mode: opts.mode })
|
|
return normalizeTile({ ...c, mode: c.mode || opts.mode })
|
|
}).filter(Boolean)
|
|
if (opts.replace) d.tiles = incoming
|
|
else {
|
|
const have = new Set(d.tiles.map((t) => t.chart))
|
|
for (const t of incoming) {
|
|
if (!have.has(t.chart)) {
|
|
d.tiles.push(t)
|
|
have.add(t.chart)
|
|
} else if (opts.mode) {
|
|
const hit = d.tiles.find((x) => x.chart === t.chart)
|
|
if (hit) hit.mode = normalizeChartMode(opts.mode)
|
|
}
|
|
}
|
|
}
|
|
d.updatedAt = Date.now()
|
|
return { ok: true, dashboard: snapshot(d) }
|
|
}
|
|
|
|
function removeTiles(dashId, charts) {
|
|
const d = get(dashId)
|
|
if (!d) return { ok: false, error: 'dashboard not found' }
|
|
const removeSet = new Set(
|
|
(Array.isArray(charts) ? charts : [charts]).map(String)
|
|
)
|
|
d.tiles = d.tiles.filter((t) => !removeSet.has(t.chart) && !removeSet.has(t.id))
|
|
d.updatedAt = Date.now()
|
|
return { ok: true, dashboard: snapshot(d) }
|
|
}
|
|
|
|
function snapshot(d) {
|
|
return { ...d, tiles: d.tiles.map((t) => ({ ...t })) }
|
|
}
|
|
|
|
function activeId() {
|
|
return active
|
|
}
|
|
|
|
return {
|
|
all,
|
|
get,
|
|
getActive,
|
|
setActive,
|
|
upsert,
|
|
remove,
|
|
addTiles,
|
|
removeTiles,
|
|
activeId,
|
|
/** @returns {{ dashboards: CustomDashboard[], activeDashboardId: string|null }} */
|
|
serialize() {
|
|
return {
|
|
dashboards: all(),
|
|
activeDashboardId: active,
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* els: {
|
|
* root: HTMLElement|null,
|
|
* list: HTMLElement|null,
|
|
* title: HTMLElement|null,
|
|
* grid: HTMLElement|null,
|
|
* empty: HTMLElement|null,
|
|
* nameInput?: HTMLInputElement|null,
|
|
* descInput?: HTMLInputElement|null,
|
|
* addSelect?: HTMLSelectElement|null,
|
|
* addBtn?: HTMLElement|null,
|
|
* newBtn?: HTMLElement|null,
|
|
* deleteBtn?: HTMLElement|null,
|
|
* saveBtn?: HTMLElement|null,
|
|
* editToggle?: HTMLElement|null,
|
|
* meta?: HTMLElement|null,
|
|
* },
|
|
* getCatalog: () => Record<string, object>,
|
|
* request: (method: string, args?: object) => Promise<any>,
|
|
* getStore: () => ReturnType<typeof createDashboardStore>,
|
|
* persist: (patch: { customDashboards?: any[], activeDashboardId?: string|null }) => void,
|
|
* isConnected?: () => boolean,
|
|
* log?: (msg: string) => void,
|
|
* }} opts
|
|
*/
|
|
export function createCustomDashboardView(opts) {
|
|
let editing = false
|
|
/** @type {ReturnType<typeof setInterval>|null} */
|
|
let pollTimer = null
|
|
let afterSeconds = 300
|
|
|
|
function store() {
|
|
return opts.getStore()
|
|
}
|
|
|
|
function persist() {
|
|
const s = store().serialize()
|
|
opts.persist({
|
|
customDashboards: s.dashboards,
|
|
activeDashboardId: s.activeDashboardId,
|
|
})
|
|
}
|
|
|
|
function enter() {
|
|
editing = false
|
|
syncEditChrome()
|
|
render()
|
|
startPoll()
|
|
}
|
|
|
|
function leave() {
|
|
stopPoll()
|
|
}
|
|
|
|
function startPoll() {
|
|
stopPoll()
|
|
pollTimer = setInterval(() => {
|
|
if (opts.isConnected?.() === false) return
|
|
paintAllTiles().catch(() => {})
|
|
}, 3000)
|
|
}
|
|
|
|
function stopPoll() {
|
|
if (pollTimer) clearInterval(pollTimer)
|
|
pollTimer = null
|
|
}
|
|
|
|
function render() {
|
|
renderList()
|
|
renderBoard()
|
|
}
|
|
|
|
function renderList() {
|
|
const host = opts.els.list
|
|
if (!host) return
|
|
host.innerHTML = ''
|
|
const all = store().all()
|
|
const active = store().activeId()
|
|
if (!all.length) {
|
|
host.innerHTML = `<p class="muted dashboards-list-empty">No dashboards yet. Create one or ask QVAC to build one.</p>`
|
|
return
|
|
}
|
|
for (const d of all) {
|
|
const btn = document.createElement('button')
|
|
btn.type = 'button'
|
|
btn.className = `dashboard-list-item${d.id === active ? ' active' : ''}`
|
|
btn.innerHTML = `<strong>${esc(d.name)}</strong><span class="muted">${d.tiles.length} charts</span>`
|
|
btn.title = d.description || d.name
|
|
btn.addEventListener('click', () => {
|
|
store().setActive(d.id)
|
|
persist()
|
|
render()
|
|
})
|
|
host.appendChild(btn)
|
|
}
|
|
}
|
|
|
|
function renderBoard() {
|
|
const d = store().getActive()
|
|
const title = opts.els.title
|
|
const grid = opts.els.grid
|
|
const empty = opts.els.empty
|
|
const meta = opts.els.meta
|
|
if (title) title.textContent = d?.name || 'Dashboards'
|
|
if (meta) {
|
|
meta.textContent = d
|
|
? `${d.tiles.length} tiles · ${d.description || 'Custom board'}${editing ? ' · editing' : ''}`
|
|
: 'Create a dashboard to pin live charts'
|
|
}
|
|
if (opts.els.nameInput) opts.els.nameInput.value = d?.name || ''
|
|
if (opts.els.descInput) opts.els.descInput.value = d?.description || ''
|
|
|
|
fillAddSelect()
|
|
|
|
if (!grid) return
|
|
grid.innerHTML = ''
|
|
if (!d || !d.tiles.length) {
|
|
empty?.classList.remove('hidden')
|
|
return
|
|
}
|
|
empty?.classList.add('hidden')
|
|
for (const tile of d.tiles) {
|
|
grid.appendChild(buildTileEl(d.id, tile))
|
|
}
|
|
paintAllTiles().catch(() => {})
|
|
}
|
|
|
|
function fillAddSelect() {
|
|
const sel = opts.els.addSelect
|
|
if (!sel) return
|
|
const catalog = opts.getCatalog() || {}
|
|
const ids = Object.keys(catalog).sort()
|
|
const prev = sel.value
|
|
sel.innerHTML =
|
|
`<option value="">Add chart…</option>` +
|
|
ids
|
|
.slice(0, 800)
|
|
.map((id) => {
|
|
const t = catalog[id]?.title || id
|
|
return `<option value="${escAttr(id)}">${esc(id)} — ${esc(t)}</option>`
|
|
})
|
|
.join('')
|
|
if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev
|
|
}
|
|
|
|
function buildTileEl(dashId, tile) {
|
|
const catalog = opts.getCatalog() || {}
|
|
const meta = catalog[tile.chart] || {}
|
|
const article = document.createElement('article')
|
|
article.className = 'dashboard-tile'
|
|
article.dataset.tileId = tile.id
|
|
article.dataset.chart = tile.chart
|
|
const mode = tile.mode || 'area'
|
|
article.innerHTML = `
|
|
<header class="dashboard-tile-head">
|
|
<div>
|
|
<h3>${esc(tile.title || meta.title || tile.chart)}</h3>
|
|
<span class="muted dashboard-tile-id">${esc(tile.chart)}</span>
|
|
</div>
|
|
<div class="dashboard-tile-actions ${editing ? '' : 'hidden'}">
|
|
<select class="dashboard-tile-mode" title="Chart type" aria-label="Chart type">
|
|
${['line', 'area', 'stacked', 'bar', 'multibar', 'pie']
|
|
.map(
|
|
(m) =>
|
|
`<option value="${m}"${m === mode ? ' selected' : ''}>${CHART_MODE_LABEL[m] || m}</option>`
|
|
)
|
|
.join('')}
|
|
</select>
|
|
<button type="button" class="btn btn-ghost dashboard-tile-remove" title="Remove">✕</button>
|
|
</div>
|
|
</header>
|
|
<canvas class="dashboard-tile-canvas" height="120"></canvas>
|
|
<footer class="muted dashboard-tile-foot">—</footer>
|
|
`
|
|
article.querySelector('.dashboard-tile-remove')?.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
store().removeTiles(dashId, [tile.chart])
|
|
persist()
|
|
render()
|
|
})
|
|
article.querySelector('.dashboard-tile-mode')?.addEventListener('change', (ev) => {
|
|
const v = /** @type {HTMLSelectElement} */ (ev.target).value
|
|
const d = store().get(dashId)
|
|
const t = d?.tiles.find((x) => x.id === tile.id)
|
|
if (t) {
|
|
t.mode = normalizeChartMode(v)
|
|
d.updatedAt = Date.now()
|
|
persist()
|
|
paintTile(article, t).catch(() => {})
|
|
}
|
|
})
|
|
return article
|
|
}
|
|
|
|
async function paintAllTiles() {
|
|
const grid = opts.els.grid
|
|
if (!grid) return
|
|
const d = store().getActive()
|
|
if (!d) return
|
|
const jobs = []
|
|
for (const el of grid.querySelectorAll('.dashboard-tile')) {
|
|
const chart = el.getAttribute('data-chart') || ''
|
|
const tile = d.tiles.find((t) => t.chart === chart)
|
|
if (tile) jobs.push(paintTile(/** @type {HTMLElement} */ (el), tile))
|
|
}
|
|
await Promise.all(jobs)
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} el
|
|
* @param {DashTile} tile
|
|
*/
|
|
async function paintTile(el, tile) {
|
|
const canvas = /** @type {HTMLCanvasElement|null} */ (el.querySelector('canvas'))
|
|
const foot = el.querySelector('.dashboard-tile-foot')
|
|
if (!canvas) return
|
|
if (opts.isConnected?.() === false) {
|
|
if (foot) foot.textContent = 'Not connected'
|
|
return
|
|
}
|
|
try {
|
|
const q = await opts.request('queryData', {
|
|
chart: tile.chart,
|
|
after: -afterSeconds,
|
|
points: Math.min(180, afterSeconds),
|
|
group: 'average',
|
|
})
|
|
const labels = (q.labels || []).filter((l) => l && l !== 'time')
|
|
const data = Array.isArray(q.data) ? q.data : []
|
|
if (!labels.length || data.length < 2) {
|
|
if (foot) foot.textContent = 'No data'
|
|
drawChart(canvas, [], { emptyMessage: 'No data', maxPoints: 60 })
|
|
return
|
|
}
|
|
/** @type {Array<{ values: number[], color: string, label: string }>} */
|
|
const lines = []
|
|
for (let di = 0; di < Math.min(4, labels.length); di++) {
|
|
const full = data.map((row) => {
|
|
const v = Number(row[di + 1])
|
|
return Number.isFinite(v) ? v : 0
|
|
})
|
|
if (full.every((v) => v === 0) && full.length > 2) {
|
|
// keep — may be legit
|
|
}
|
|
lines.push({
|
|
values: full,
|
|
color: seriesColor(di),
|
|
label: labels[di],
|
|
})
|
|
}
|
|
const mode = normalizeChartMode(tile.mode || 'area')
|
|
drawChart(canvas, lines, {
|
|
mode: mode === 'pie' || mode === 'bar' || mode === 'multibar' ? mode : mode === 'stacked' ? 'stacked' : mode,
|
|
maxPoints: 120,
|
|
showYAxis: true,
|
|
padLeft: 36,
|
|
emptyMessage: 'No data',
|
|
})
|
|
const last = lines[0]?.values[lines[0].values.length - 1]
|
|
if (foot) {
|
|
foot.textContent = `${lines.map((l) => l.label).join(', ')} · last ${fmt(last)} · ${afterSeconds}s`
|
|
}
|
|
} catch (err) {
|
|
if (foot) foot.textContent = err?.message || 'query failed'
|
|
}
|
|
}
|
|
|
|
function syncEditChrome() {
|
|
const on = editing
|
|
opts.els.editToggle?.classList.toggle('active', on)
|
|
opts.els.root?.classList.toggle('is-editing', on)
|
|
opts.els.nameInput?.closest('.dashboard-edit-fields')?.classList.toggle('hidden', !on)
|
|
for (const el of opts.els.grid?.querySelectorAll('.dashboard-tile-actions') || []) {
|
|
el.classList.toggle('hidden', !on)
|
|
}
|
|
}
|
|
|
|
function bind() {
|
|
opts.els.newBtn?.addEventListener('click', () => {
|
|
const d = store().upsert({
|
|
name: `Dashboard ${store().all().length + 1}`,
|
|
description: '',
|
|
tiles: [],
|
|
})
|
|
store().setActive(d.id)
|
|
persist()
|
|
editing = true
|
|
syncEditChrome()
|
|
render()
|
|
})
|
|
opts.els.deleteBtn?.addEventListener('click', () => {
|
|
const d = store().getActive()
|
|
if (!d) return
|
|
if (!confirm(`Delete dashboard “${d.name}”?`)) return
|
|
store().remove(d.id)
|
|
persist()
|
|
render()
|
|
})
|
|
opts.els.saveBtn?.addEventListener('click', () => {
|
|
const d = store().getActive()
|
|
if (!d) return
|
|
store().upsert({
|
|
id: d.id,
|
|
name: opts.els.nameInput?.value || d.name,
|
|
description: opts.els.descInput?.value || '',
|
|
})
|
|
persist()
|
|
editing = false
|
|
syncEditChrome()
|
|
render()
|
|
})
|
|
opts.els.editToggle?.addEventListener('click', () => {
|
|
editing = !editing
|
|
syncEditChrome()
|
|
renderBoard()
|
|
})
|
|
opts.els.addBtn?.addEventListener('click', () => {
|
|
const d = store().getActive()
|
|
const chart = opts.els.addSelect?.value
|
|
if (!d || !chart) return
|
|
store().addTiles(d.id, [chart], { mode: 'area' })
|
|
persist()
|
|
render()
|
|
})
|
|
}
|
|
|
|
// ── Agent API ──────────────────────────────────────────────────────────
|
|
|
|
function listDashboards() {
|
|
return {
|
|
ok: true,
|
|
activeDashboardId: store().activeId(),
|
|
dashboards: store().all().map((d) => ({
|
|
id: d.id,
|
|
name: d.name,
|
|
description: d.description,
|
|
tileCount: d.tiles.length,
|
|
charts: d.tiles.map((t) => t.chart),
|
|
updatedAt: d.updatedAt,
|
|
})),
|
|
}
|
|
}
|
|
|
|
function createDashboard(args = {}) {
|
|
const tiles = parseTilesArg(args)
|
|
const d = store().upsert({
|
|
name: args.name || 'Agent dashboard',
|
|
description: args.description || '',
|
|
tiles,
|
|
})
|
|
store().setActive(d.id)
|
|
persist()
|
|
render()
|
|
return { ok: true, dashboard: store().get(d.id) }
|
|
}
|
|
|
|
function updateDashboard(args = {}) {
|
|
const id = args.id || args.dashboardId || store().activeId()
|
|
if (!id || !store().get(id)) return { ok: false, error: 'dashboard not found' }
|
|
const patch = { id }
|
|
if (args.name != null) patch.name = args.name
|
|
if (args.description != null) patch.description = args.description
|
|
if (args.tiles != null) patch.tiles = parseTilesArg(args)
|
|
store().upsert(patch)
|
|
persist()
|
|
render()
|
|
return { ok: true, dashboard: store().get(id) }
|
|
}
|
|
|
|
function deleteDashboard(args = {}) {
|
|
const id = args.id || args.dashboardId
|
|
if (!id) return { ok: false, error: 'id required' }
|
|
const ok = store().remove(id)
|
|
if (!ok) return { ok: false, error: 'dashboard not found' }
|
|
persist()
|
|
render()
|
|
return { ok: true, deleted: id, activeDashboardId: store().activeId() }
|
|
}
|
|
|
|
function addDashboardCharts(args = {}) {
|
|
const id = args.id || args.dashboardId || store().activeId()
|
|
if (!id) return { ok: false, error: 'no active dashboard — create one first' }
|
|
const charts = parseChartsArg(args)
|
|
if (!charts.length) return { ok: false, error: 'charts required' }
|
|
const res = store().addTiles(id, charts, {
|
|
mode: args.mode,
|
|
replace: Boolean(args.replace),
|
|
})
|
|
persist()
|
|
render()
|
|
return res
|
|
}
|
|
|
|
function removeDashboardCharts(args = {}) {
|
|
const id = args.id || args.dashboardId || store().activeId()
|
|
if (!id) return { ok: false, error: 'no active dashboard' }
|
|
const charts = parseChartsArg(args)
|
|
if (!charts.length) return { ok: false, error: 'charts required' }
|
|
const res = store().removeTiles(id, charts)
|
|
persist()
|
|
render()
|
|
return res
|
|
}
|
|
|
|
function openDashboard(args = {}) {
|
|
const id = args.id || args.dashboardId
|
|
if (id) store().setActive(id)
|
|
persist()
|
|
render()
|
|
return {
|
|
ok: true,
|
|
activeDashboardId: store().activeId(),
|
|
dashboard: store().getActive(),
|
|
}
|
|
}
|
|
|
|
function parseChartsArg(args) {
|
|
if (Array.isArray(args.charts)) return args.charts.map(String)
|
|
if (args.chart) return [String(args.chart)]
|
|
if (typeof args.charts === 'string') {
|
|
return args.charts.split(/[\s,]+/).filter(Boolean)
|
|
}
|
|
if (typeof args.tiles === 'string') {
|
|
return args.tiles.split(/[\s,]+/).filter(Boolean)
|
|
}
|
|
return []
|
|
}
|
|
|
|
function parseTilesArg(args) {
|
|
if (Array.isArray(args.tiles)) {
|
|
return args.tiles.map(normalizeTile).filter(Boolean)
|
|
}
|
|
return parseChartsArg(args).map((chart) =>
|
|
normalizeTile({ chart, mode: args.mode || 'area' })
|
|
)
|
|
}
|
|
|
|
bind()
|
|
|
|
return {
|
|
enter,
|
|
leave,
|
|
render,
|
|
listDashboards,
|
|
createDashboard,
|
|
updateDashboard,
|
|
deleteDashboard,
|
|
addDashboardCharts,
|
|
removeDashboardCharts,
|
|
openDashboard,
|
|
getStore: store,
|
|
}
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
function escAttr(s) {
|
|
return esc(s).replace(/'/g, ''')
|
|
}
|
|
|
|
function fmt(v) {
|
|
const n = Number(v)
|
|
if (!Number.isFinite(n)) return '—'
|
|
if (Math.abs(n) >= 100) return n.toFixed(0)
|
|
if (Math.abs(n) >= 10) return n.toFixed(1)
|
|
return n.toFixed(2)
|
|
}
|