diff --git a/bun.lock b/bun.lock index 75b3f42..973bd6a 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "salesforce-app", + "dependencies": { + "chartjs-plugin-datalabels": "^2.2.0", + }, "devDependencies": { "@playwright/test": "^1.58.0", "@screenly/edge-apps": "^1.0.0", @@ -541,6 +544,8 @@ "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "chartjs-plugin-datalabels": ["chartjs-plugin-datalabels@2.2.0", "", { "peerDependencies": { "chart.js": ">=3.0.0" } }, "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw=="], + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index b2d87c6..e06578a 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -87,6 +87,42 @@ const MOCK_DASHBOARD_RESPONSE = { }, status: { componentDataStatus: 'DATA', refreshStatus: 'IDLE' }, }, + { + componentId: 'comp-metric', + reportResult: { + factMap: { + 'T!T': { aggregates: [{ label: '42', value: 42 }] }, + }, + groupingsDown: { groupings: [] }, + groupingsAcross: { groupings: [] }, + reportExtendedMetadata: { + aggregateColumnInfo: { + RowCount: { dataType: 'int', label: 'Record Count' }, + }, + detailColumnInfo: {}, + }, + reportMetadata: { aggregates: ['RowCount'], detailColumns: [] }, + }, + status: { componentDataStatus: 'DATA', refreshStatus: 'IDLE' }, + }, + { + componentId: 'comp-gauge', + reportResult: { + factMap: { + 'T!T': { aggregates: [{ label: '135', value: 135 }] }, + }, + groupingsDown: { groupings: [] }, + groupingsAcross: { groupings: [] }, + reportExtendedMetadata: { + aggregateColumnInfo: { + RowCount: { dataType: 'int', label: 'Record Count' }, + }, + detailColumnInfo: {}, + }, + reportMetadata: { aggregates: ['RowCount'], detailColumns: [] }, + }, + status: { componentDataStatus: 'DATA', refreshStatus: 'IDLE' }, + }, { componentId: 'comp-table', reportResult: { @@ -176,8 +212,50 @@ const MOCK_DASHBOARD_RESPONSE = { }, }, { - id: 'comp-table', + id: 'comp-metric', componentData: 3, + header: 'Total Cases', + title: null, + reportId: '00O000000000005', + type: 'Report', + properties: { + visualizationType: 'Metric', + visualizationProperties: {}, + aggregates: [{ name: 'RowCount' }], + groupings: null, + }, + }, + { + id: 'comp-gauge', + componentData: 4, + header: 'Number of Cases', + title: null, + reportId: '00O000000000004', + type: 'Report', + properties: { + visualizationType: 'Gauge', + visualizationProperties: { + breakPoints: [ + { + aggregateName: 'RowCount', + breaks: [ + { color: 'ae4034', lowerBound: 0, upperBound: 54 }, + { color: 'f2a91e', lowerBound: 54, upperBound: 108 }, + { color: '217c3e', lowerBound: 108, upperBound: 200 }, + ], + }, + ], + showPercentages: false, + showRange: true, + showTotal: false, + }, + aggregates: [{ name: 'RowCount' }], + groupings: null, + }, + }, + { + id: 'comp-table', + componentData: 5, header: 'Account List', title: null, reportId: '00O000000000002', @@ -202,7 +280,9 @@ const MOCK_DASHBOARD_RESPONSE = { { column: 0, row: 0, colspan: 4, rowspan: 8 }, { column: 4, row: 0, colspan: 4, rowspan: 8 }, { column: 8, row: 0, colspan: 4, rowspan: 8 }, - { column: 0, row: 8, colspan: 12, rowspan: 6 }, + { column: 0, row: 8, colspan: 3, rowspan: 8 }, + { column: 3, row: 8, colspan: 5, rowspan: 8 }, + { column: 8, row: 8, colspan: 4, rowspan: 8 }, ], numColumns: 12, rowHeight: 36, diff --git a/package.json b/package.json index cfc1272..04df38f 100644 --- a/package.json +++ b/package.json @@ -31,5 +31,8 @@ "npm-run-all2": "^8.0.4", "prettier": "^3.8.1", "typescript": "^5.9.3" + }, + "dependencies": { + "chartjs-plugin-datalabels": "^2.2.0" } } diff --git a/screenshots/dashboard-1080x1920.webp b/screenshots/dashboard-1080x1920.webp index 9381a4a..69debb9 100644 Binary files a/screenshots/dashboard-1080x1920.webp and b/screenshots/dashboard-1080x1920.webp differ diff --git a/screenshots/dashboard-1280x720.webp b/screenshots/dashboard-1280x720.webp index e7416ed..e8c7ae0 100644 Binary files a/screenshots/dashboard-1280x720.webp and b/screenshots/dashboard-1280x720.webp differ diff --git a/screenshots/dashboard-1920x1080.webp b/screenshots/dashboard-1920x1080.webp index dcf1763..1a19109 100644 Binary files a/screenshots/dashboard-1920x1080.webp and b/screenshots/dashboard-1920x1080.webp differ diff --git a/screenshots/dashboard-2160x3840.webp b/screenshots/dashboard-2160x3840.webp index 0403af5..fdd6af5 100644 Binary files a/screenshots/dashboard-2160x3840.webp and b/screenshots/dashboard-2160x3840.webp differ diff --git a/screenshots/dashboard-2160x4096.webp b/screenshots/dashboard-2160x4096.webp index 9933554..5f22858 100644 Binary files a/screenshots/dashboard-2160x4096.webp and b/screenshots/dashboard-2160x4096.webp differ diff --git a/screenshots/dashboard-3840x2160.webp b/screenshots/dashboard-3840x2160.webp index e797a4f..db08c2e 100644 Binary files a/screenshots/dashboard-3840x2160.webp and b/screenshots/dashboard-3840x2160.webp differ diff --git a/screenshots/dashboard-4096x2160.webp b/screenshots/dashboard-4096x2160.webp index 7348de7..1f16a89 100644 Binary files a/screenshots/dashboard-4096x2160.webp and b/screenshots/dashboard-4096x2160.webp differ diff --git a/screenshots/dashboard-480x800.webp b/screenshots/dashboard-480x800.webp index 3035a7c..3954a28 100644 Binary files a/screenshots/dashboard-480x800.webp and b/screenshots/dashboard-480x800.webp differ diff --git a/screenshots/dashboard-720x1280.webp b/screenshots/dashboard-720x1280.webp index bc08fe7..e921f85 100644 Binary files a/screenshots/dashboard-720x1280.webp and b/screenshots/dashboard-720x1280.webp differ diff --git a/screenshots/dashboard-800x480.webp b/screenshots/dashboard-800x480.webp index bf6de47..b8dbd30 100644 Binary files a/screenshots/dashboard-800x480.webp and b/screenshots/dashboard-800x480.webp differ diff --git a/screenshots/error-2160x3840.webp b/screenshots/error-2160x3840.webp index 44df769..b120ea6 100644 Binary files a/screenshots/error-2160x3840.webp and b/screenshots/error-2160x3840.webp differ diff --git a/screenshots/error-3840x2160.webp b/screenshots/error-3840x2160.webp index 9ef41bd..f029f8c 100644 Binary files a/screenshots/error-3840x2160.webp and b/screenshots/error-3840x2160.webp differ diff --git a/screenshots/error-not-found-3840x2160.webp b/screenshots/error-not-found-3840x2160.webp index 4ef6e03..b66604c 100644 Binary files a/screenshots/error-not-found-3840x2160.webp and b/screenshots/error-not-found-3840x2160.webp differ diff --git a/screenshots/report-2160x3840.webp b/screenshots/report-2160x3840.webp index aea460f..4102b95 100644 Binary files a/screenshots/report-2160x3840.webp and b/screenshots/report-2160x3840.webp differ diff --git a/screenshots/report-3840x2160.webp b/screenshots/report-3840x2160.webp index 66c7526..327cd3f 100644 Binary files a/screenshots/report-3840x2160.webp and b/screenshots/report-3840x2160.webp differ diff --git a/src/css/style.css b/src/css/style.css index 3687840..b33d3fd 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -110,6 +110,7 @@ main { flex: 1; position: relative; min-height: 0; + container-type: size; } .report-chart-card { @@ -197,6 +198,21 @@ main { word-break: break-word; } +/* Metric component */ + +.metric-value { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: clamp(2rem, 30cqh, 20rem); + font-weight: 700; + letter-spacing: -0.04em; + color: var(--color-text-primary); + text-align: center; +} + /* Empty state */ .empty-state { diff --git a/src/render/chart.ts b/src/render/chart.ts index 25cda66..4e5c819 100644 --- a/src/render/chart.ts +++ b/src/render/chart.ts @@ -1,8 +1,9 @@ import { Chart, registerables } from 'chart.js' +import ChartDataLabels from 'chartjs-plugin-datalabels' import type { ReportResult } from '../types' import { renderEmpty } from './utils' -Chart.register(...registerables) +Chart.register(...registerables, ChartDataLabels) Chart.defaults.animation = false @@ -62,6 +63,40 @@ function extractChartData(reportResult: ReportResult): { return { labels, values } } +function buildDatalabelsConfig(chartType: 'bar' | 'line' | 'pie' | 'doughnut') { + const base = { + color: '#ffffff' as const, + font: { weight: 'bold' as const, size: 20 }, + formatter: (value: number) => (value === 0 ? '' : value.toLocaleString()), + } + if (chartType === 'bar') { + return { + ...base, + anchor: 'end' as const, + align: 'start' as const, + clamp: true, + } + } + if (chartType === 'line') { + return { + ...base, + font: { weight: 'bold' as const, size: 15 }, + anchor: 'end' as const, + align: 'top' as const, + backgroundColor: 'rgba(0,0,0,0.55)', + borderRadius: 3, + padding: { top: 2, bottom: 2, left: 4, right: 4 }, + } + } + return { ...base, anchor: 'center' as const, align: 'center' as const } +} + +function buildScalesConfig(chartType: 'bar' | 'line' | 'pie' | 'doughnut') { + if (chartType !== 'bar' && chartType !== 'line') return undefined + const axis = { ticks: { color: '#ffffff' }, grid: { color: '#ffffff22' } } + return { x: axis, y: axis } +} + export function renderChart( container: HTMLElement, componentId: string, @@ -105,20 +140,9 @@ export function renderChart( maintainAspectRatio: false, plugins: { legend: { labels: { color: '#ffffff' } }, + datalabels: buildDatalabelsConfig(chartType), }, - scales: - chartType === 'bar' || chartType === 'line' - ? { - x: { - ticks: { color: '#ffffff' }, - grid: { color: '#ffffff22' }, - }, - y: { - ticks: { color: '#ffffff' }, - grid: { color: '#ffffff22' }, - }, - } - : undefined, + scales: buildScalesConfig(chartType), }, }) } diff --git a/src/render/gauge.ts b/src/render/gauge.ts index 37e2f2a..00b2da6 100644 --- a/src/render/gauge.ts +++ b/src/render/gauge.ts @@ -18,10 +18,10 @@ function drawGaugeNeedle( cx: number, cy: number, outerR: number, - pct: number, - value: number + pct: number ): void { - const angle = Math.PI + pct * Math.PI + const safePct = Number.isFinite(pct) ? Math.max(0, Math.min(1, pct)) : 0 + const angle = Math.PI + safePct * Math.PI ctx.save() ctx.beginPath() ctx.moveTo(cx, cy) @@ -37,10 +37,40 @@ function drawGaugeNeedle( ctx.arc(cx, cy, outerR * 0.06, 0, 2 * Math.PI) ctx.fillStyle = '#ffffff' ctx.fill() - ctx.font = `bold ${Math.floor(outerR * 0.22)}px sans-serif` + ctx.restore() +} + +function drawGaugeBreakLabels( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + outerR: number, + innerR: number, + min: number, + max: number, + breaks: { lowerBound: number; upperBound: number }[] +): void { + const range = max - min + if (range <= 0) return + + const labelR = (outerR + innerR) / 2 + const fontSize = Math.max(13, Math.floor(outerR * 0.14)) + ctx.save() + ctx.font = `bold ${fontSize}px sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' - ctx.fillText(String(value), cx, cy + outerR * 0.35) + + for (const b of breaks) { + const midVal = (b.lowerBound + b.upperBound) / 2 + const pct = (midVal - min) / range + const angle = Math.PI + pct * Math.PI + const x = cx + labelR * Math.cos(angle) + const y = cy + labelR * Math.sin(angle) + + ctx.fillStyle = '#ffffff' + ctx.fillText(String(b.upperBound), x, y) + } + ctx.restore() } @@ -77,7 +107,17 @@ export function renderGauge( const { ctx } = chart const arcEl = chart.getDatasetMeta(0).data[0] as ArcElement if (!arcEl) return - drawGaugeNeedle(ctx, arcEl.x, arcEl.y, arcEl.outerRadius, pct, value) + drawGaugeBreakLabels( + ctx, + arcEl.x, + arcEl.y, + arcEl.outerRadius, + arcEl.innerRadius, + min, + max, + breaks + ) + drawGaugeNeedle(ctx, arcEl.x, arcEl.y, arcEl.outerRadius, pct) }, } @@ -101,6 +141,7 @@ export function renderGauge( plugins: { legend: { display: false }, tooltip: { enabled: false }, + datalabels: { display: false }, }, }, plugins: [needlePlugin], diff --git a/src/render/index.ts b/src/render/index.ts index 1811bf2..174da8d 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -4,6 +4,7 @@ import type { ComponentDataItem, ReportResult, } from '../types' + import { CHART_TYPES, renderChart } from './chart' import { renderGauge } from './gauge' import { renderTable } from './table' @@ -25,6 +26,8 @@ function renderComponent( if (sfTypeLower === 'gauge') { renderGauge(container, item.componentId, item.reportResult, meta) + } else if (sfTypeLower === 'metric') { + renderMetric(container, item.reportResult, meta) } else if (CHART_TYPES.has(sfTypeLower)) { renderChart(container, item.componentId, item.reportResult, sfType, title) } else { @@ -105,6 +108,26 @@ function renderStat( container.appendChild(stat) } +function renderMetric( + container: HTMLElement, + reportResult: ReportResult, + _meta: DashboardMetadataComponent +): void { + const entry = reportResult.factMap?.['T!T'] + + if (!entry) { + renderEmpty(container) + return + } + + const value = entry.aggregates?.[0]?.value ?? 0 + + const el = document.createElement('div') + el.className = 'metric-value' + el.textContent = Number(value).toLocaleString() + container.appendChild(el) +} + function hasReportDetailRows(reportResult: ReportResult): boolean { const rows = Object.values(reportResult.factMap ?? {}).flatMap( (entry) => entry.rows ?? []