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..e03e6ec 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, @@ -311,18 +391,41 @@ const { screenlyJsContent: reportScreenlyJsContent } = } ) +const { screenlyJsContent: dashboardLabelsScreenlyJsContent } = + createMockScreenlyForScreenshots( + { coordinates: [37.3861, -122.0839], location: 'Silicon Valley, USA' }, + { + content_id: MOCK_DASHBOARD_ID, + refresh_interval: '300', + display_errors: 'false', + show_labels: 'true', + screenly_oauth_tokens_url: 'http://localhost:3000/', + screenly_app_auth_token: 'mock-token', + } + ) + +const { screenlyJsContent: reportLabelsScreenlyJsContent } = + createMockScreenlyForScreenshots( + { coordinates: [37.3861, -122.0839], location: 'Silicon Valley, USA' }, + { + content_id: MOCK_REPORT_ID, + refresh_interval: '300', + display_errors: 'false', + show_labels: 'true', + screenly_oauth_tokens_url: 'http://localhost:3000/', + screenly_app_auth_token: 'mock-token', + } + ) + +type BrowserContext = Awaited> + async function takeScreenshot( browser: Browser, width: number, height: number, filename: string, screenlyJsContent: string, - setup: (context: Awaited>) => Promise, - waitFor: ( - page: Awaited< - ReturnType>['newPage']> - > - ) => Promise + setup: (context: BrowserContext) => Promise ): Promise { const screenshotsDir = getScreenshotsDir() const context = await browser.newContext({ viewport: { width, height } }) @@ -333,7 +436,7 @@ async function takeScreenshot( await setup(context) await page.goto('/?animations=false') - await waitFor(page) + await page.waitForLoadState('networkidle') await page.screenshot({ path: path.join(screenshotsDir, filename), @@ -343,6 +446,45 @@ async function takeScreenshot( await context.close() } +function mockCredentials(context: BrowserContext): Promise { + return context.route(/access_token\//, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_CREDENTIALS), + }) + ) +} + +async function setupDashboardRoutes( + context: BrowserContext, + dashboardStatus: number +): Promise { + await mockCredentials(context) + await context.route(/analytics\/dashboards/, (route) => + route.fulfill({ + status: dashboardStatus, + contentType: 'application/json', + body: JSON.stringify( + dashboardStatus === 200 + ? MOCK_DASHBOARD_RESPONSE + : { message: dashboardStatus === 404 ? 'Not Found' : 'Unauthorized' } + ), + }) + ) +} + +async function setupReportRoutes(context: BrowserContext): Promise { + await mockCredentials(context) + await context.route(/analytics\/reports/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_REPORT_RESPONSE), + }) + ) +} + for (const { width, height } of RESOLUTIONS) { test(`screenshot dashboard ${width}x${height}`, async ({ browser }) => { await takeScreenshot( @@ -351,25 +493,7 @@ for (const { width, height } of RESOLUTIONS) { height, `dashboard-${width}x${height}.png`, dashboardScreenlyJsContent, - async (context) => { - await context.route(/access_token\//, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(MOCK_CREDENTIALS), - }) - }) - await context.route(/analytics\/dashboards/, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(MOCK_DASHBOARD_RESPONSE), - }) - }) - }, - async (page) => { - await page.waitForLoadState('networkidle') - } + (context) => setupDashboardRoutes(context, 200) ) }) } @@ -385,25 +509,7 @@ for (const [width, height] of [ height, `error-${width}x${height}.png`, dashboardScreenlyJsContent, - async (context) => { - await context.route(/access_token\//, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(MOCK_CREDENTIALS), - }) - }) - await context.route(/analytics\/dashboards/, async (route) => { - await route.fulfill({ - status: 401, - contentType: 'application/json', - body: JSON.stringify({ message: 'Unauthorized' }), - }) - }) - }, - async (page) => { - await page.waitForLoadState('networkidle') - } + (context) => setupDashboardRoutes(context, 401) ) }) } @@ -415,28 +521,26 @@ test('screenshot error-not-found 3840x2160', async ({ browser }) => { 2160, 'error-not-found-3840x2160.png', dashboardScreenlyJsContent, - async (context) => { - await context.route(/access_token\//, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(MOCK_CREDENTIALS), - }) - }) - await context.route(/analytics\/dashboards/, async (route) => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - }) - }, - async (page) => { - await page.waitForLoadState('networkidle') - } + (context) => setupDashboardRoutes(context, 404) ) }) +for (const [width, height] of [ + [1920, 1080], + [1080, 1920], +]) { + test(`screenshot dashboard-labels ${width}x${height}`, async ({ browser }) => { + await takeScreenshot( + browser, + width, + height, + `dashboard-labels-${width}x${height}.png`, + dashboardLabelsScreenlyJsContent, + (context) => setupDashboardRoutes(context, 200) + ) + }) +} + for (const [width, height] of [ [3840, 2160], [2160, 3840], @@ -448,25 +552,23 @@ for (const [width, height] of [ height, `report-${width}x${height}.png`, reportScreenlyJsContent, - async (context) => { - await context.route(/access_token\//, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(MOCK_CREDENTIALS), - }) - }) - await context.route(/analytics\/reports/, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(MOCK_REPORT_RESPONSE), - }) - }) - }, - async (page) => { - await page.waitForLoadState('networkidle') - } + (context) => setupReportRoutes(context) + ) + }) +} + +for (const [width, height] of [ + [1920, 1080], + [1080, 1920], +]) { + test(`screenshot report-labels ${width}x${height}`, async ({ browser }) => { + await takeScreenshot( + browser, + width, + height, + `report-labels-${width}x${height}.png`, + reportLabelsScreenlyJsContent, + (context) => setupReportRoutes(context) ) }) } 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/screenly.yml b/screenly.yml index f962cfe..8d30555 100644 --- a/screenly.yml +++ b/screenly.yml @@ -49,3 +49,13 @@ settings: help_text: How often to refresh Salesforce data. Defaults to 300 seconds (5 minutes). type: number schema_version: 1 + show_labels: + type: string + default_value: 'false' + title: Show Labels + optional: true + help_text: + properties: + help_text: Show data labels on charts and gauges. + type: boolean + schema_version: 1 diff --git a/screenly_qc.yml b/screenly_qc.yml index 8a681a4..ec62b6f 100644 --- a/screenly_qc.yml +++ b/screenly_qc.yml @@ -49,3 +49,13 @@ settings: help_text: How often to refresh Salesforce data. Defaults to 300 seconds (5 minutes). type: number schema_version: 1 + show_labels: + type: string + default_value: 'false' + title: Show Labels + optional: true + help_text: + properties: + help_text: Show data labels on charts and gauges. + type: boolean + schema_version: 1 diff --git a/screenshots/dashboard-1080x1920.webp b/screenshots/dashboard-1080x1920.webp index 9381a4a..15ca156 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..e4b645f 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..4be54ed 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..c90e18f 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..ef7ed9a 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..1ebe379 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..4e3d860 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..f408f9d 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..0340a90 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..185737e 100644 Binary files a/screenshots/dashboard-800x480.webp and b/screenshots/dashboard-800x480.webp differ diff --git a/screenshots/dashboard-labels-1080x1920.webp b/screenshots/dashboard-labels-1080x1920.webp new file mode 100644 index 0000000..69debb9 Binary files /dev/null and b/screenshots/dashboard-labels-1080x1920.webp differ diff --git a/screenshots/dashboard-labels-1920x1080.webp b/screenshots/dashboard-labels-1920x1080.webp new file mode 100644 index 0000000..1a19109 Binary files /dev/null and b/screenshots/dashboard-labels-1920x1080.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..3db3263 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..252d840 100644 Binary files a/screenshots/report-3840x2160.webp and b/screenshots/report-3840x2160.webp differ diff --git a/screenshots/report-labels-1080x1920.webp b/screenshots/report-labels-1080x1920.webp new file mode 100644 index 0000000..0dfae35 Binary files /dev/null and b/screenshots/report-labels-1080x1920.webp differ diff --git a/screenshots/report-labels-1920x1080.webp b/screenshots/report-labels-1920x1080.webp new file mode 100644 index 0000000..66da504 Binary files /dev/null and b/screenshots/report-labels-1920x1080.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/main.ts b/src/main.ts index ab092d0..fee9f8e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,8 @@ async function loadAndRenderContent( contentType: SalesforceContentType, instanceUrl: string, accessToken: string, - contentId: string + contentId: string, + showLabels: boolean ): Promise { if (contentType === 'dashboard') { const results = await getDashboardResults( @@ -31,12 +32,12 @@ async function loadAndRenderContent( accessToken, contentId ) - renderDashboard(results) + renderDashboard(results, showLabels) return } const results = await getReportResults(instanceUrl, accessToken, contentId) - renderReport(contentId, results) + renderReport(contentId, results, showLabels) } function handleError(message: string, displayErrors: boolean): void { @@ -49,7 +50,8 @@ async function fetchAndRender( contentType: SalesforceContentType, getRuntimeState: () => RuntimeState, refreshToken: RefreshToken, - displayErrors: boolean + displayErrors: boolean, + showLabels: boolean ): Promise { let { accessToken, instanceUrl } = getRuntimeState() const { credentialError } = getRuntimeState() @@ -63,7 +65,13 @@ async function fetchAndRender( } try { - await loadAndRenderContent(contentType, instanceUrl, accessToken, contentId) + await loadAndRenderContent( + contentType, + instanceUrl, + accessToken, + contentId, + showLabels + ) showScreen('dashboard-screen') return } catch (err) { @@ -89,7 +97,13 @@ async function fetchAndRender( return } - await loadAndRenderContent(contentType, instanceUrl, accessToken, contentId) + await loadAndRenderContent( + contentType, + instanceUrl, + accessToken, + contentId, + showLabels + ) showScreen('dashboard-screen') } catch (retryErr) { handleError( @@ -105,9 +119,11 @@ document.addEventListener('DOMContentLoaded', async () => { setupErrorHandling() const contentId = getSettingWithDefault('content_id', '') - const refreshInterval = getSettingWithDefault('refresh_interval', 300) const displayErrors = getSettingWithDefault('display_errors', 'false') === 'true' + const refreshInterval = getSettingWithDefault('refresh_interval', 300) + const showLabels = + getSettingWithDefault('show_labels', 'false') === 'true' if (!contentId) { showError('Please configure the Salesforce Content ID in settings.') @@ -157,7 +173,8 @@ document.addEventListener('DOMContentLoaded', async () => { contentType, getRuntimeState, refreshToken, - displayErrors + displayErrors, + showLabels ) await run() diff --git a/src/render/chart.ts b/src/render/chart.ts index 25cda66..6f0efb9 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,12 +63,47 @@ 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, reportResult: ReportResult, sfType: string, - title: string + title: string, + showLabels: boolean = false ): void { const { labels, values } = extractChartData(reportResult) @@ -105,20 +141,11 @@ export function renderChart( maintainAspectRatio: false, plugins: { legend: { labels: { color: '#ffffff' } }, + datalabels: showLabels + ? buildDatalabelsConfig(chartType) + : { display: false }, }, - 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..bb5ef14 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,18 +37,80 @@ 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() } +function buildNeedlePlugin( + componentId: string, + min: number, + max: number, + breaks: { lowerBound: number; upperBound: number }[], + pct: number, + showLabels: boolean +) { + return { + id: `gaugeNeedle-${componentId}`, + afterDraw(chart: Chart) { + const { ctx } = chart + const arcEl = chart.getDatasetMeta(0).data[0] as ArcElement + if (!arcEl) return + if (showLabels) { + drawGaugeBreakLabels( + ctx, + arcEl.x, + arcEl.y, + arcEl.outerRadius, + arcEl.innerRadius, + min, + max, + breaks + ) + } + drawGaugeNeedle(ctx, arcEl.x, arcEl.y, arcEl.outerRadius, pct) + }, + } +} + export function renderGauge( container: HTMLElement, componentId: string, reportResult: ReportResult, - meta: DashboardMetadataComponent + meta: DashboardMetadataComponent, + showLabels: boolean = false ): void { const value = Number( reportResult.factMap?.['T!T']?.aggregates?.[0]?.value ?? 0 @@ -71,15 +133,14 @@ export function renderGauge( canvas.id = `chart-${componentId}` container.appendChild(canvas) - const needlePlugin = { - id: `gaugeNeedle-${componentId}`, - afterDraw(chart: Chart) { - 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) - }, - } + const needlePlugin = buildNeedlePlugin( + componentId, + min, + max, + breaks, + pct, + showLabels + ) const gaugeConfig: ChartConfiguration<'doughnut'> = { type: 'doughnut', @@ -101,6 +162,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..c69ea0e 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -4,15 +4,23 @@ import type { ComponentDataItem, ReportResult, } from '../types' + import { CHART_TYPES, renderChart } from './chart' import { renderGauge } from './gauge' import { renderTable } from './table' import { renderEmpty } from './utils' +import { + createChartCard, + renderMetric, + renderReportContent, + setupReportGrid, +} from './report' function renderComponent( container: HTMLElement, meta: DashboardMetadataComponent, - item: ComponentDataItem + item: ComponentDataItem, + showLabels: boolean ): void { if (!item.reportResult || item.status.componentDataStatus === 'NO_DATA') { renderEmpty(container) @@ -24,9 +32,24 @@ function renderComponent( const title = meta.header ?? meta.title ?? '' if (sfTypeLower === 'gauge') { - renderGauge(container, item.componentId, item.reportResult, meta) + renderGauge( + container, + item.componentId, + item.reportResult, + meta, + showLabels + ) + } else if (sfTypeLower === 'metric') { + renderMetric(container, item.reportResult, meta) } else if (CHART_TYPES.has(sfTypeLower)) { - renderChart(container, item.componentId, item.reportResult, sfType, title) + renderChart( + container, + item.componentId, + item.reportResult, + sfType, + title, + showLabels + ) } else { renderTable(container, meta, item.reportResult) } @@ -42,181 +65,10 @@ function getDashboardElements(): { } } -function createChartCard(titleText: string): { - card: HTMLDivElement - contentContainer: HTMLDivElement -} { - const card = document.createElement('div') - card.className = 'chart-card' - - const title = document.createElement('h3') - title.className = 'chart-title' - title.textContent = titleText - card.appendChild(title) - - const contentContainer = document.createElement('div') - contentContainer.className = 'chart-container' - card.appendChild(contentContainer) - - return { card, contentContainer } -} - -function appendReportCard( - chartsGrid: HTMLElement, - titleText: string, - options?: { - cardClassName?: string - containerClassName?: string - } -): HTMLDivElement { - const { card, contentContainer } = createChartCard(titleText) - card.style.gridColumn = '1 / span 12' - - if (options?.cardClassName) { - card.classList.add(options.cardClassName) - } - - if (options?.containerClassName) { - contentContainer.classList.add(options.containerClassName) - } - - chartsGrid.appendChild(card) - return contentContainer -} - -function renderStat( - container: HTMLElement, - label: string, - value: string -): void { - const stat = document.createElement('div') - stat.className = 'report-stat' - - const statValue = document.createElement('div') - statValue.className = 'report-stat-value' - statValue.textContent = value - - const statLabel = document.createElement('div') - statLabel.className = 'report-stat-label' - statLabel.textContent = label - - stat.appendChild(statValue) - stat.appendChild(statLabel) - container.appendChild(stat) -} - -function hasReportDetailRows(reportResult: ReportResult): boolean { - const rows = Object.values(reportResult.factMap ?? {}).flatMap( - (entry) => entry.rows ?? [] - ) - const detailColumns = reportResult.reportMetadata?.detailColumns ?? [] - - return ( - (reportResult.hasDetailRows ?? true) && - rows.length > 0 && - detailColumns.length > 0 - ) -} - -function hasGroupedReportData(reportResult: ReportResult): boolean { - const groupings = reportResult.groupingsDown?.groupings ?? [] - const keys = Object.keys(reportResult.factMap ?? {}) - - return groupings.length > 0 && keys.some((key) => key !== 'T!T') -} - -function getReportChartType(reportResult: ReportResult): string | null { - const chartType = reportResult.reportMetadata?.chart?.chartType - - return chartType ? chartType : null -} - -function setupReportGrid( - chartsGrid: HTMLElement, - dashboardTitle: HTMLElement | null, - reportName: string +export function renderDashboard( + results: DashboardResults, + showLabels: boolean = false ): void { - if (dashboardTitle) { - dashboardTitle.textContent = reportName - } - - chartsGrid.innerHTML = '' - chartsGrid.style.gridTemplateColumns = 'repeat(12, 1fr)' - chartsGrid.style.gridAutoRows = 'minmax(6rem, auto)' -} - -function renderReportChartCard( - chartsGrid: HTMLElement, - contentId: string, - reportResult: ReportResult, - reportName: string, - chartType: string = 'Column' -): boolean { - if (!hasGroupedReportData(reportResult)) { - return false - } - - const chartContainer = appendReportCard(chartsGrid, reportName, { - cardClassName: 'report-chart-card', - containerClassName: 'report-chart-container', - }) - - renderChart(chartContainer, contentId, reportResult, chartType, reportName) - return true -} - -function renderReportTableCard( - chartsGrid: HTMLElement, - contentId: string, - reportResult: ReportResult, - reportName: string -): boolean { - if (!hasReportDetailRows(reportResult)) { - return false - } - - const meta = { - id: contentId, - header: reportName, - title: reportName, - reportId: contentId, - type: 'Report', - properties: { - visualizationType: 'FlexTable', - visualizationProperties: {}, - aggregates: [], - groupings: null, - }, - } - - const tableContainer = appendReportCard(chartsGrid, `${reportName} Details`, { - cardClassName: 'report-table-card', - }) - - renderTable(tableContainer, meta, reportResult) - return true -} - -function renderReportFallbackCard( - chartsGrid: HTMLElement, - reportName: string, - aggregateLabel: string, - aggregateValue: number | null -): void { - if (aggregateValue !== null) { - const statContainer = appendReportCard(chartsGrid, reportName, { - cardClassName: 'report-stat-card', - }) - - renderStat(statContainer, aggregateLabel, String(aggregateValue)) - return - } - - const emptyContainer = appendReportCard(chartsGrid, reportName) - renderEmpty(emptyContainer) -} - -export function renderDashboard(results: DashboardResults): void { const { dashboardTitle, chartsGrid } = getDashboardElements() if (!chartsGrid) return @@ -260,53 +112,29 @@ export function renderDashboard(results: DashboardResults): void { card.style.gridRow = `${pos.row + 1} / span ${pos.rowspan}` } - renderComponent(contentContainer, meta, item) + renderComponent(contentContainer, meta, item, showLabels) chartsGrid.appendChild(card) } } export function renderReport( contentId: string, - reportResult: ReportResult + reportResult: ReportResult, + showLabels: boolean = false ): void { const { dashboardTitle, chartsGrid } = getDashboardElements() if (!chartsGrid) return const reportName = reportResult.reportMetadata?.name ?? `Report ${contentId}` - const aggregateLabel = - reportResult.factMap?.['T!T']?.aggregates?.[0]?.label ?? - reportResult.reportMetadata?.aggregates?.[0] ?? - 'Value' - const aggregateValue = - reportResult.factMap?.['T!T']?.aggregates?.[0]?.value ?? null - const reportChartType = getReportChartType(reportResult) setupReportGrid(chartsGrid, dashboardTitle, reportName) - - const renderedChart = renderReportChartCard( + renderReportContent( chartsGrid, contentId, reportResult, reportName, - reportChartType ?? 'Column' - ) - const renderedTable = renderReportTableCard( - chartsGrid, - contentId, - reportResult, - reportName - ) - - if (renderedChart || renderedTable) { - return - } - - renderReportFallbackCard( - chartsGrid, - reportName, - aggregateLabel, - aggregateValue + showLabels ) } diff --git a/src/render/report.ts b/src/render/report.ts new file mode 100644 index 0000000..3d759b6 --- /dev/null +++ b/src/render/report.ts @@ -0,0 +1,245 @@ +import type { DashboardMetadataComponent, ReportResult } from '../types' +import { renderChart } from './chart' +import { renderTable } from './table' +import { renderEmpty } from './utils' + +export function createChartCard(titleText: string): { + card: HTMLDivElement + contentContainer: HTMLDivElement +} { + const card = document.createElement('div') + card.className = 'chart-card' + + const title = document.createElement('h3') + title.className = 'chart-title' + title.textContent = titleText + card.appendChild(title) + + const contentContainer = document.createElement('div') + contentContainer.className = 'chart-container' + card.appendChild(contentContainer) + + return { card, contentContainer } +} + +export function appendReportCard( + chartsGrid: HTMLElement, + titleText: string, + options?: { + cardClassName?: string + containerClassName?: string + } +): HTMLDivElement { + const { card, contentContainer } = createChartCard(titleText) + card.style.gridColumn = '1 / span 12' + + if (options?.cardClassName) { + card.classList.add(options.cardClassName) + } + + if (options?.containerClassName) { + contentContainer.classList.add(options.containerClassName) + } + + chartsGrid.appendChild(card) + return contentContainer +} + +function renderStat( + container: HTMLElement, + label: string, + value: string +): void { + const stat = document.createElement('div') + stat.className = 'report-stat' + + const statValue = document.createElement('div') + statValue.className = 'report-stat-value' + statValue.textContent = value + + const statLabel = document.createElement('div') + statLabel.className = 'report-stat-label' + statLabel.textContent = label + + stat.appendChild(statValue) + stat.appendChild(statLabel) + container.appendChild(stat) +} + +export 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 ?? [] + ) + const detailColumns = reportResult.reportMetadata?.detailColumns ?? [] + + return ( + (reportResult.hasDetailRows ?? true) && + rows.length > 0 && + detailColumns.length > 0 + ) +} + +function hasGroupedReportData(reportResult: ReportResult): boolean { + const groupings = reportResult.groupingsDown?.groupings ?? [] + const keys = Object.keys(reportResult.factMap ?? {}) + + return groupings.length > 0 && keys.some((key) => key !== 'T!T') +} + +function getReportChartType(reportResult: ReportResult): string | null { + const chartType = reportResult.reportMetadata?.chart?.chartType + + return chartType ? chartType : null +} + +export function setupReportGrid( + chartsGrid: HTMLElement, + dashboardTitle: HTMLElement | null, + reportName: string +): void { + if (dashboardTitle) { + dashboardTitle.textContent = reportName + } + + chartsGrid.innerHTML = '' + chartsGrid.style.gridTemplateColumns = 'repeat(12, 1fr)' + chartsGrid.style.gridAutoRows = 'minmax(6rem, auto)' +} + +function renderReportChartCard( + chartsGrid: HTMLElement, + contentId: string, + reportResult: ReportResult, + reportName: string, + chartType: string = 'Column', + showLabels: boolean = false +): boolean { + if (!hasGroupedReportData(reportResult)) { + return false + } + + const chartContainer = appendReportCard(chartsGrid, reportName, { + cardClassName: 'report-chart-card', + containerClassName: 'report-chart-container', + }) + + renderChart( + chartContainer, + contentId, + reportResult, + chartType, + reportName, + showLabels + ) + return true +} + +function renderReportTableCard( + chartsGrid: HTMLElement, + contentId: string, + reportResult: ReportResult, + reportName: string +): boolean { + if (!hasReportDetailRows(reportResult)) { + return false + } + + const meta: DashboardMetadataComponent = { + id: contentId, + header: reportName, + title: reportName, + reportId: contentId, + type: 'Report', + properties: { + visualizationType: 'FlexTable', + visualizationProperties: {}, + aggregates: [], + groupings: null, + }, + } + + const tableContainer = appendReportCard(chartsGrid, `${reportName} Details`, { + cardClassName: 'report-table-card', + }) + + renderTable(tableContainer, meta, reportResult) + return true +} + +function renderReportFallbackCard( + chartsGrid: HTMLElement, + reportName: string, + aggregateLabel: string, + aggregateValue: number | null +): void { + if (aggregateValue !== null) { + const statContainer = appendReportCard(chartsGrid, reportName, { + cardClassName: 'report-stat-card', + }) + + renderStat(statContainer, aggregateLabel, String(aggregateValue)) + return + } + + const emptyContainer = appendReportCard(chartsGrid, reportName) + renderEmpty(emptyContainer) +} + +export function renderReportContent( + chartsGrid: HTMLElement, + contentId: string, + reportResult: ReportResult, + reportName: string, + showLabels: boolean +): void { + const renderedChart = renderReportChartCard( + chartsGrid, + contentId, + reportResult, + reportName, + getReportChartType(reportResult) ?? 'Column', + showLabels + ) + const renderedTable = renderReportTableCard( + chartsGrid, + contentId, + reportResult, + reportName + ) + + if (renderedChart || renderedTable) return + + const aggregateLabel = + reportResult.factMap?.['T!T']?.aggregates?.[0]?.label ?? + reportResult.reportMetadata?.aggregates?.[0] ?? + 'Value' + const aggregateValue = + reportResult.factMap?.['T!T']?.aggregates?.[0]?.value ?? null + + renderReportFallbackCard( + chartsGrid, + reportName, + aggregateLabel, + aggregateValue + ) +}