salesforce-reports-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,32 +5,178 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Salesforce report</title>
7
7
  <style>
8
+ /*
9
+ * Salesforce Lightning Design System token values for the report card and the dashboard card (decision 0018).
10
+ * Values only: no SLDS stylesheet, no Salesforce Sans font file, nothing loaded from the network.
11
+ * scripts/build-widget.ts inlines this file into both cards in place of their tokens marker.
12
+ *
13
+ * Source: @salesforce-ux/design-system 2.264.1 (npm, BSD-3-Clause), design-tokens/dist:
14
+ * font.raw.json font-family (the fallbacks after "Salesforce Sans")
15
+ * theme-one-salesforce.json FONT_SIZE_2 to FONT_SIZE_9, FONT_WEIGHT_REGULAR, FONT_WEIGHT_BOLD, LINE_HEIGHT_TEXT,
16
+ * LINE_HEIGHT_HEADING, SPACING_XXX_SMALL to SPACING_MEDIUM, BORDER_RADIUS_SMALL,
17
+ * BORDER_RADIUS_MEDIUM, CARD_COLOR_BORDER, CARD_SHADOW, BRAND_ACCESSIBLE,
18
+ * BRAND_ACCESSIBLE_ACTIVE
19
+ * slds-hooks.raw.json link-color, link-color-hover (shadow-outset-focus-1 is drawn from --link)
20
+ * palettes.json color-palette-neutral-*, color-palette-blue-70 and -80 (dark links),
21
+ * color-palette-red-60, color-palette-orange-65, color-palette-green-60 (gauge bands)
22
+ * The data-visualization palettes are not in the package's token files. They come from the Lightning Design System
23
+ * guidelines, Data Visualization, Charts, "Chart Color": the Default Palette and the named Bluegrass, Sunrise,
24
+ * Water and Watermelon palettes, each in the guideline's order.
25
+ *
26
+ * Copyright (c) 2015, salesforce.com, inc. All rights reserved.
27
+ *
28
+ * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
29
+ * following conditions are met: Redistributions of source code must retain the above copyright notice, this list of
30
+ * conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice,
31
+ * this list of conditions and the following disclaimer in the documentation and/or other materials provided with the
32
+ * distribution. Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or
33
+ * promote products derived from this software without specific prior written permission.
34
+ *
35
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
36
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
37
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
38
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
39
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
40
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
41
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
42
+ */
8
43
  :root {
9
- --fallback-bg: #ffffff;
10
- --fallback-bg-2: #f4f6f9;
11
- --fallback-text: #181818;
12
- --fallback-text-2: #5c5c5c;
13
- --fallback-border: #dddbda;
14
- --fallback-accent: #0b5cab;
44
+ /* Type: the font stack, the type scale, weights and line heights. */
45
+ --slds-font-family: "Salesforce Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
46
+ --slds-font-size-2: 0.75rem;
47
+ --slds-font-size-3: 0.8125rem;
48
+ --slds-font-size-4: 0.875rem;
49
+ --slds-font-size-5: 1rem;
50
+ --slds-font-size-6: 1.125rem;
51
+ --slds-font-size-8: 1.5rem;
52
+ --slds-font-size-9: 1.75rem;
53
+ --slds-font-weight-regular: 400;
54
+ --slds-font-weight-bold: 700;
55
+ --slds-line-height-text: 1.5;
56
+ --slds-line-height-heading: 1.25;
57
+
58
+ /* Spacing steps. */
59
+ --slds-spacing-xxx-small: 0.125rem;
60
+ --slds-spacing-xx-small: 0.25rem;
61
+ --slds-spacing-x-small: 0.5rem;
62
+ --slds-spacing-small: 0.75rem;
63
+ --slds-spacing-medium: 1rem;
64
+
65
+ /* The card: border, radius and shadow. */
66
+ --slds-radius-small: 0.125rem;
67
+ --slds-card-radius: 0.25rem;
68
+ --slds-card-border: rgb(201, 201, 201);
69
+ --slds-card-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.10);
70
+
71
+ /* Brand blue: buttons, links and the focus ring. */
72
+ --slds-brand-accessible: rgb(1, 118, 211);
73
+ --slds-brand-accessible-active: rgb(1, 68, 134);
74
+ --slds-link: #0b5cab;
75
+ --slds-link-hover: #014486;
76
+ --slds-link-dark: rgb(120, 176, 253);
77
+ --slds-link-dark-hover: rgb(170, 203, 255);
78
+
79
+ /* Neutrals, light and dark. */
80
+ --slds-neutral-10: rgb(24, 24, 24);
81
+ --slds-neutral-15: rgb(36, 36, 36);
82
+ --slds-neutral-20: rgb(46, 46, 46);
83
+ --slds-neutral-30: rgb(68, 68, 68);
84
+ --slds-neutral-50: rgb(116, 116, 116);
85
+ --slds-neutral-70: rgb(174, 174, 174);
86
+ --slds-neutral-80: rgb(201, 201, 201);
87
+ --slds-neutral-90: rgb(229, 229, 229);
88
+ --slds-neutral-95: rgb(243, 243, 243);
89
+ --slds-neutral-100: rgb(255, 255, 255);
90
+
91
+ /* Gauge bands when a dashboard sets none: fills only, never text. */
92
+ --slds-red-60: rgb(254, 92, 76);
93
+ --slds-orange-65: rgb(243, 131, 3);
94
+ --slds-green-60: rgb(59, 167, 85);
95
+
96
+ /* The data-visualization Default Palette, in order. */
97
+ --slds-dataviz-1: #52B7D8;
98
+ --slds-dataviz-2: #E16032;
99
+ --slds-dataviz-3: #FFB03B;
100
+ --slds-dataviz-4: #54A77B;
101
+ --slds-dataviz-5: #4FD2D2;
102
+ --slds-dataviz-6: #E287B2;
103
+
104
+ /* The named chart palettes a dashboard can choose, in order. */
105
+ --slds-dataviz-bluegrass-1: #C7F296;
106
+ --slds-dataviz-bluegrass-2: #94E7A8;
107
+ --slds-dataviz-bluegrass-3: #51D2BB;
108
+ --slds-dataviz-bluegrass-4: #27AAB0;
109
+ --slds-dataviz-bluegrass-5: #116985;
110
+ --slds-dataviz-bluegrass-6: #053661;
111
+ --slds-dataviz-sunrise-1: #F5DE98;
112
+ --slds-dataviz-sunrise-2: #F5C062;
113
+ --slds-dataviz-sunrise-3: #F59623;
114
+ --slds-dataviz-sunrise-4: #CE6716;
115
+ --slds-dataviz-sunrise-5: #762F3D;
116
+ --slds-dataviz-sunrise-6: #300561;
117
+ --slds-dataviz-water-1: #96F2EE;
118
+ --slds-dataviz-water-2: #68CEEE;
119
+ --slds-dataviz-water-3: #2D9CED;
120
+ --slds-dataviz-water-4: #0E6ECE;
121
+ --slds-dataviz-water-5: #073E92;
122
+ --slds-dataviz-water-6: #051C61;
123
+ --slds-dataviz-watermelon-1: #F598A7;
124
+ --slds-dataviz-watermelon-2: #F56580;
125
+ --slds-dataviz-watermelon-3: #F4284E;
126
+ --slds-dataviz-watermelon-4: #C11C2F;
127
+ --slds-dataviz-watermelon-5: #5C3F22;
128
+ --slds-dataviz-watermelon-6: #0A611B;
129
+ }
130
+ /* The card's own names for the SLDS values. The host's theme variables set the surface (background, text and
131
+ border) first; the SLDS neutrals are the fallbacks, light and dark. */
132
+ :root {
133
+ --fallback-bg: var(--slds-neutral-100);
134
+ --fallback-bg-2: var(--slds-neutral-95);
135
+ --fallback-text: var(--slds-neutral-10);
136
+ --fallback-text-2: var(--slds-neutral-30);
137
+ --fallback-border: var(--slds-card-border);
138
+ --fallback-rule: var(--slds-neutral-90);
139
+ --link: var(--slds-link);
140
+ --link-hover: var(--slds-link-hover);
141
+ --series-1: var(--slds-dataviz-1);
142
+ --series-other: var(--slds-neutral-50);
143
+ --track-mix: 18%;
144
+ --heat-cool: 60%;
145
+ --heat-hot: 85%;
146
+ --heat-ink: var(--text);
15
147
  color-scheme: light dark;
16
148
  }
17
149
  @media (prefers-color-scheme: dark) {
18
150
  :root:not([data-theme="light"]) {
19
- --fallback-bg: #1f1f1e;
20
- --fallback-bg-2: #2b2b29;
21
- --fallback-text: #ececea;
22
- --fallback-text-2: #a8a8a3;
23
- --fallback-border: #3d3d3a;
24
- --fallback-accent: #78b0fd;
151
+ --fallback-bg: var(--slds-neutral-15);
152
+ --fallback-bg-2: var(--slds-neutral-20);
153
+ --fallback-text: var(--slds-neutral-95);
154
+ --fallback-text-2: var(--slds-neutral-70);
155
+ --fallback-border: var(--slds-neutral-30);
156
+ --fallback-rule: var(--slds-neutral-30);
157
+ --link: var(--slds-link-dark);
158
+ --link-hover: var(--slds-link-dark-hover);
159
+ --series-other: var(--slds-neutral-70);
160
+ --track-mix: 24%;
161
+ --heat-cool: 50%;
162
+ --heat-hot: 80%;
163
+ --heat-ink: var(--slds-neutral-10);
25
164
  }
26
165
  }
27
166
  :root[data-theme="dark"] {
28
- --fallback-bg: #1f1f1e;
29
- --fallback-bg-2: #2b2b29;
30
- --fallback-text: #ececea;
31
- --fallback-text-2: #a8a8a3;
32
- --fallback-border: #3d3d3a;
33
- --fallback-accent: #78b0fd;
167
+ --fallback-bg: var(--slds-neutral-15);
168
+ --fallback-bg-2: var(--slds-neutral-20);
169
+ --fallback-text: var(--slds-neutral-95);
170
+ --fallback-text-2: var(--slds-neutral-70);
171
+ --fallback-border: var(--slds-neutral-30);
172
+ --fallback-rule: var(--slds-neutral-30);
173
+ --link: var(--slds-link-dark);
174
+ --link-hover: var(--slds-link-dark-hover);
175
+ --series-other: var(--slds-neutral-70);
176
+ --track-mix: 24%;
177
+ --heat-cool: 50%;
178
+ --heat-hot: 80%;
179
+ --heat-ink: var(--slds-neutral-10);
34
180
  }
35
181
  :root {
36
182
  --bg: var(--color-background-primary, var(--fallback-bg));
@@ -38,65 +184,188 @@
38
184
  --text: var(--color-text-primary, var(--fallback-text));
39
185
  --text-2: var(--color-text-secondary, var(--fallback-text-2));
40
186
  --border: var(--color-border-primary, var(--fallback-border));
41
- --accent: var(--color-text-info, var(--fallback-accent));
42
- --radius: var(--border-radius-md, 8px);
43
- --radius-full: var(--border-radius-full, 999px);
187
+ --rule: var(--color-border-primary, var(--fallback-rule));
188
+ /* SLDS shadow-outset-focus-1 (2px of the surface, then 2px of link blue), drawn in the theme's own colours. */
189
+ --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--link);
190
+ --caption: var(--slds-font-size-2);
44
191
  }
45
192
  * { box-sizing: border-box; }
46
193
  html, body { margin: 0; padding: 0; }
47
194
  body {
48
195
  background: var(--bg);
49
196
  color: var(--text);
50
- font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
51
- font-size: var(--font-text-sm-size, 14px);
52
- line-height: var(--font-text-sm-line-height, 1.45);
197
+ font-family: var(--slds-font-family);
198
+ font-size: var(--slds-font-size-3);
199
+ line-height: var(--slds-line-height-text);
53
200
  }
54
- .card { padding: 16px; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
55
- .head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; }
201
+ a, button, summary { border-radius: var(--slds-card-radius); }
202
+ a:focus-visible, button:focus-visible, summary:focus-visible { outline: none; box-shadow: var(--focus-ring); }
203
+ .card { padding: var(--slds-spacing-medium); display: flex; flex-direction: column; gap: var(--slds-spacing-small); min-width: 0; }
204
+ .head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--slds-spacing-x-small); }
56
205
  h1 {
57
206
  margin: 0;
58
- font-size: var(--font-heading-sm-size, 17px);
59
- line-height: var(--font-heading-sm-line-height, 1.3);
60
- font-weight: var(--font-weight-semibold, 600);
207
+ font-size: var(--slds-font-size-6);
208
+ line-height: var(--slds-line-height-heading);
209
+ font-weight: var(--slds-font-weight-bold);
61
210
  overflow-wrap: anywhere;
62
211
  }
63
- h1 a { color: inherit; text-decoration: none; }
64
- h1 a:hover { text-decoration: underline; }
65
- .meta { color: var(--text-2); font-size: var(--font-text-xs-size, 12px); }
66
- .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
212
+ h1 a { color: var(--link); text-decoration: none; }
213
+ h1 a:hover { color: var(--link-hover); text-decoration: underline; }
214
+ .meta { color: var(--text-2); font-size: var(--caption); }
215
+ .chips { display: flex; flex-wrap: wrap; gap: var(--slds-spacing-xx-small); margin: 0; padding: 0; list-style: none; }
67
216
  .chips li {
68
- background: var(--bg-2);
217
+ background: var(--bg);
69
218
  border: 1px solid var(--border);
70
- border-radius: var(--radius-full);
71
- padding: 2px 10px;
72
- font-size: var(--font-text-xs-size, 12px);
73
- color: var(--text-2);
219
+ border-radius: var(--slds-card-radius);
220
+ padding: var(--slds-spacing-xxx-small) var(--slds-spacing-x-small);
221
+ font-size: var(--caption);
222
+ color: var(--text);
74
223
  overflow-wrap: anywhere;
75
224
  }
76
- .scroll { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); }
225
+ .scroll { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--slds-card-radius); }
77
226
  table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
78
- th, td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
79
- th { background: var(--bg-2); color: var(--text-2); font-weight: var(--font-weight-medium, 500); font-size: var(--font-text-xs-size, 12px); }
227
+ th, td { padding: var(--slds-spacing-xx-small) var(--slds-spacing-x-small); text-align: left; border-bottom: 1px solid var(--rule); white-space: nowrap; }
228
+ th { background: var(--bg-2); color: var(--text-2); font-weight: var(--slds-font-weight-bold); font-size: var(--caption); }
80
229
  td.num, th.num { text-align: right; }
81
230
  tbody tr:last-child td { border-bottom: none; }
82
- tr.total td { font-weight: var(--font-weight-semibold, 600); background: var(--bg-2); border-top: 1px solid var(--border); }
83
- .section-title { margin: 0 0 6px; font-size: var(--font-text-xs-size, 12px); color: var(--text-2); font-weight: var(--font-weight-medium, 500); text-transform: uppercase; letter-spacing: 0.04em; }
84
- .cut { margin: 6px 0 0; color: var(--text-2); font-size: var(--font-text-xs-size, 12px); }
85
- .notes { margin: 0; padding-left: 18px; color: var(--text-2); font-size: var(--font-text-xs-size, 12px); }
86
- .actions { display: flex; flex-wrap: wrap; gap: 8px; }
231
+ tr.total td { font-weight: var(--slds-font-weight-bold); background: var(--bg-2); border-top: 1px solid var(--rule); }
232
+ .section-title { margin: 0 0 var(--slds-spacing-x-small); font-size: var(--caption); color: var(--text-2); font-weight: var(--slds-font-weight-regular); text-transform: uppercase; letter-spacing: 0.0625rem; }
233
+ .cut { margin: var(--slds-spacing-xx-small) 0 0; color: var(--text-2); font-size: var(--caption); }
234
+ .notes { margin: 0; padding-left: var(--slds-spacing-medium); color: var(--text-2); font-size: var(--caption); }
235
+ .actions { display: flex; flex-wrap: wrap; gap: var(--slds-spacing-x-small); }
236
+ /* SLDS buttons: neutral (Open in Salesforce) and brand (Save this report). */
87
237
  button {
88
238
  font: inherit;
89
- font-size: var(--font-text-sm-size, 14px);
90
- padding: 6px 12px;
91
- border-radius: var(--radius);
239
+ font-size: var(--slds-font-size-3);
240
+ line-height: 1.875rem;
241
+ padding: 0 var(--slds-spacing-medium);
242
+ border-radius: var(--slds-card-radius);
92
243
  border: 1px solid var(--border);
93
244
  background: var(--bg);
94
- color: var(--text);
245
+ color: var(--link);
95
246
  cursor: pointer;
96
247
  }
97
- button.primary { background: var(--accent); border-color: var(--accent); color: var(--bg); }
248
+ button:hover { background: var(--bg-2); color: var(--link-hover); }
249
+ button.primary { background: var(--slds-brand-accessible); border-color: var(--slds-brand-accessible); color: var(--slds-neutral-100); }
250
+ button.primary:hover { background: var(--slds-brand-accessible-active); border-color: var(--slds-brand-accessible-active); color: var(--slds-neutral-100); }
98
251
  button:disabled { opacity: 0.6; cursor: default; }
99
252
  .empty { color: var(--text-2); }
253
+ td.blank { color: var(--text-2); }
254
+ tbody th { background: none; color: var(--text); font-weight: var(--slds-font-weight-regular); font-size: inherit; }
255
+
256
+ /* The KPI strip: the answer before the rows. */
257
+ .kpis { display: flex; flex-wrap: wrap; gap: var(--slds-spacing-x-small); }
258
+ .kpi {
259
+ flex: 1 1 150px;
260
+ min-width: 0;
261
+ max-width: 240px;
262
+ display: flex;
263
+ flex-direction: column;
264
+ gap: var(--slds-spacing-xxx-small);
265
+ padding: var(--slds-spacing-x-small) var(--slds-spacing-small);
266
+ border: 1px solid var(--border);
267
+ border-radius: var(--slds-card-radius);
268
+ background: var(--bg);
269
+ }
270
+ .kpi-label { color: var(--text-2); font-size: var(--caption); overflow-wrap: anywhere; }
271
+ .kpi-value {
272
+ font-size: var(--slds-font-size-8);
273
+ line-height: var(--slds-line-height-heading);
274
+ font-weight: var(--slds-font-weight-bold);
275
+ font-variant-numeric: tabular-nums;
276
+ overflow-wrap: anywhere;
277
+ }
278
+ .kpi-sub { color: var(--text-2); font-size: var(--caption); overflow-wrap: anywhere; }
279
+
280
+ /* Data bars: a track and the number side by side, so a bar never sits under text.
281
+ The number keeps one width per column, so every track has the same length and bars compare across rows.
282
+ One measure is one series, so every bar takes the first colour of the data-visualization palette. */
283
+ .barcell { display: flex; align-items: center; gap: var(--slds-spacing-x-small); }
284
+ .bar {
285
+ flex: 1 1 auto;
286
+ min-width: 64px;
287
+ height: 10px;
288
+ border-radius: var(--slds-radius-small);
289
+ background: color-mix(in srgb, var(--series-1) var(--track-mix), transparent);
290
+ overflow: hidden;
291
+ }
292
+ .bar i {
293
+ display: block;
294
+ height: 100%;
295
+ width: var(--bar, 0%);
296
+ border-radius: var(--slds-radius-small);
297
+ background: var(--series-1);
298
+ transform-origin: left center;
299
+ animation: grow 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
300
+ }
301
+ .barcell .v { flex: none; text-align: right; }
302
+ @keyframes grow { from { transform: scaleX(0); } }
303
+ @media (prefers-reduced-motion: reduce) {
304
+ .bar i { animation: none; }
305
+ }
306
+
307
+ /* The matrix grid: each cell shaded in the first palette colour by its value against the largest cell (--heat).
308
+ Cool cells (below 60%) shade up to --heat-cool under the normal text. Hot cells jump to --heat-hot and above,
309
+ with the text in --heat-ink: the normal text in light, where the palette colour is light enough under it, and
310
+ the darkest SLDS neutral in dark, so both bands stay AA. --t is the cell's place within its band, from 0 to 1. */
311
+ td.heat { background: color-mix(in srgb, var(--series-1) calc(var(--t, 0) * var(--heat-cool)), transparent); }
312
+ td.heat.hot {
313
+ background: color-mix(in srgb, var(--series-1) calc(var(--heat-hot) + var(--t, 0) * (100% - var(--heat-hot))), transparent);
314
+ color: var(--heat-ink);
315
+ font-weight: var(--slds-font-weight-bold);
316
+ }
317
+ .grid tr.total td, .grid td.rowtotal { background: var(--bg-2); font-weight: var(--slds-font-weight-bold); }
318
+ .grid td.rowtotal { border-left: 1px solid var(--rule); }
319
+ .all-groups summary { cursor: pointer; margin: 0; }
320
+ .all-groups details[open] summary { margin-bottom: var(--slds-spacing-x-small); }
321
+
322
+ /* The column chart above a summary's groups: one column per top-level group, in inline SVG.
323
+ Columns sit in percent slots, so the chart fills any width; labels are fitted to their slot in script. */
324
+ .chart { display: block; width: 100%; height: 172px; overflow: visible; }
325
+ .chart text { font-family: inherit; font-size: var(--caption); fill: var(--text-2); }
326
+ .chart text.value { fill: var(--text); font-weight: var(--slds-font-weight-bold); font-variant-numeric: tabular-nums; }
327
+ .chart line.base { stroke: var(--border); stroke-width: 1; }
328
+ .chart rect.col {
329
+ fill: var(--series-1);
330
+ transform-box: fill-box;
331
+ transform-origin: center bottom;
332
+ animation: rise 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
333
+ }
334
+ .chart rect.col.other { fill: var(--series-other); }
335
+ @keyframes rise { from { transform: scaleY(0); } }
336
+ @media (prefers-reduced-motion: reduce) {
337
+ .chart rect.col { animation: none; }
338
+ }
339
+
340
+ /* At phone width a folded table stops being a grid of columns. Each row becomes a small grid of its own:
341
+ the grouping labels stack on the left, the lead measure and its bar sit on the right, and every other
342
+ column stacks under the row as "label value". Nothing scrolls sideways. */
343
+ @media (max-width: 480px) {
344
+ /* One tile a row, so a headline number never breaks across lines. */
345
+ .kpi { flex-basis: 100%; max-width: none; }
346
+ table.fold, table.fold thead, table.fold tbody { display: block; }
347
+ table.fold tr { display: grid; grid-template-columns: minmax(0, 1fr); border-bottom: 1px solid var(--rule); }
348
+ table.fold.led tr { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); }
349
+ table.fold tbody tr:last-child { border-bottom: none; }
350
+ table.fold thead tr, table.fold tr.total { background: var(--bg-2); }
351
+ table.fold tr.total { border-top: 1px solid var(--rule); }
352
+ table.fold th, table.fold td { border: none; white-space: normal; overflow-wrap: break-word; background: none; }
353
+ table.fold .key { grid-column: 1; }
354
+ table.fold .key ~ .key { padding-top: 0; color: var(--text-2); }
355
+ table.fold .lead { grid-column: 2; grid-row: 1 / span var(--keys, 1); display: flex; align-items: center; justify-content: flex-end; }
356
+ table.fold .lead .barcell { flex: 1 1 auto; }
357
+ table.fold .lead .bar { min-width: 32px; }
358
+ table.fold th.extra { display: none; }
359
+ table.fold td.extra {
360
+ grid-column: 1 / -1;
361
+ display: flex;
362
+ justify-content: space-between;
363
+ gap: var(--slds-spacing-small);
364
+ padding-top: 0;
365
+ font-size: var(--caption);
366
+ }
367
+ table.fold td.extra::before { content: attr(data-label); color: var(--text-2); text-align: left; font-weight: normal; }
368
+ }
100
369
  </style>
101
370
  </head>
102
371
  <body>
@@ -123,25 +392,234 @@ function el(tag, attrs, children) {
123
392
  }
124
393
 
125
394
  const NUMERIC = new Set(["currency", "double", "int", "percent", "number"]);
395
+ const BLANK = "-";
396
+
397
+ // Formats use the reader's locale. ReportResult carries no currency code, so currency draws as US dollars
398
+ // (decision 0015). Whole amounts drop the cents; an amount with cents keeps two places.
126
399
  const numberFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
400
+ const moneyWhole = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", minimumFractionDigits: 0, maximumFractionDigits: 0 });
401
+ const moneyCents = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: 2 });
402
+ const shareFormat = new Intl.NumberFormat(undefined, { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1 });
403
+ // A date has no time zone, so it is drawn in UTC to keep its day. A datetime is drawn in the reader's zone.
404
+ const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeZone: "UTC" });
405
+ const dateTimeFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
406
+
407
+ function isBlank(value) {
408
+ return value === null || value === undefined || value === "";
409
+ }
410
+
411
+ function formatDate(value, dataType) {
412
+ const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
413
+ if (day) return dateFormat.format(new Date(Date.UTC(Number(day[1]), Number(day[2]) - 1, Number(day[3]))));
414
+ if (dataType === "datetime" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
415
+ const time = new Date(value);
416
+ if (!Number.isNaN(time.getTime())) return dateTimeFormat.format(time);
417
+ }
418
+ return value;
419
+ }
127
420
 
128
421
  function cellText(value, dataType) {
129
- if (value === null || value === undefined) return "";
130
- if (typeof value === "number") return numberFormat.format(value) + (dataType === "percent" ? "%" : "");
422
+ if (isBlank(value)) return BLANK;
423
+ if (typeof value === "number") {
424
+ if (dataType === "currency") return (Number.isInteger(value) ? moneyWhole : moneyCents).format(value);
425
+ if (dataType === "percent") return numberFormat.format(value) + "%";
426
+ return numberFormat.format(value);
427
+ }
131
428
  if (typeof value === "boolean") return value ? "Yes" : "No";
429
+ if (dataType === "date" || dataType === "datetime") return formatDate(String(value), dataType);
132
430
  return String(value);
133
431
  }
134
432
 
135
433
  function cell(tag, value, dataType) {
136
- const numeric = typeof value === "number" || NUMERIC.has(dataType);
137
- return el(tag, { text: cellText(value, dataType), class: numeric ? "num" : "" });
434
+ const classes = [];
435
+ if (typeof value === "number" || NUMERIC.has(dataType)) classes.push("num");
436
+ if (isBlank(value)) classes.push("blank");
437
+ return el(tag, { text: cellText(value, dataType), class: classes.join(" ") });
438
+ }
439
+
440
+ // A ratio as a CSS percent, to three places, for a bar width or a heat level.
441
+ function percent(ratio) {
442
+ const clamped = Math.max(0, Math.min(1, ratio));
443
+ return String(Math.round(clamped * 100000) / 1000) + "%";
444
+ }
445
+
446
+ // Put a data bar beside a number cell: a track on the left, the number on the right in a width that is the
447
+ // same for the whole column (chars, the longest value in it). The bar is decoration; the number stays the text.
448
+ function addBar(td, ratio, chars) {
449
+ const text = td.textContent;
450
+ const fill = el("i");
451
+ fill.style.setProperty("--bar", percent(ratio));
452
+ const value = el("span", { class: "v", text });
453
+ value.style.setProperty("min-width", chars + "ch");
454
+ td.classList.add("barred");
455
+ td.replaceChildren(el("div", { class: "barcell" }, [el("span", { class: "bar", "aria-hidden": "true" }, [fill]), value]));
456
+ }
457
+
458
+ // options.bar = { column, max } draws a bar in that column of each body row.
459
+ // options.keys and options.lead fold the table at phone width: the first `keys` columns stay as the row's
460
+ // labels, column `lead` sits beside them, and every other column stacks under the row with its header as
461
+ // a label (CSS reads it from data-label). Without options, a table keeps its first column and stacks the rest.
462
+ function table(headers, rows, totalRow, options) {
463
+ const bar = options && options.bar;
464
+ const keys = options && options.keys !== undefined ? options.keys : 1;
465
+ const lead = options && options.lead !== undefined ? options.lead : -1;
466
+ const fold = lead >= 0 || headers.length > keys;
467
+ const mark = (node, i) => {
468
+ if (!fold) return;
469
+ if (i === lead) node.classList.add("lead");
470
+ else if (i < keys) node.classList.add("key");
471
+ else {
472
+ node.classList.add("extra");
473
+ node.setAttribute("data-label", headers[i] ? headers[i].label : "");
474
+ }
475
+ };
476
+ const chars = bar ? Math.max(...rows.map((row) => cellText(row[bar.column], headers[bar.column] && headers[bar.column].dataType).length)) : 0;
477
+ const head = el("thead", {}, [el("tr", {}, headers.map((h, i) => {
478
+ const th = el("th", { text: h.label, class: h.numeric ? "num" : "" });
479
+ mark(th, i);
480
+ return th;
481
+ }))]);
482
+ const body = el("tbody", {}, rows.map((row) => el("tr", {}, row.map((value, i) => {
483
+ const td = cell("td", value, headers[i] && headers[i].dataType);
484
+ if (bar && i === bar.column && typeof value === "number") addBar(td, value / bar.max, chars);
485
+ mark(td, i);
486
+ return td;
487
+ }))));
488
+ if (totalRow) {
489
+ // A total row's label may span the grouping columns; each cell is marked by the column it starts in.
490
+ let column = 0;
491
+ for (const td of totalRow.children) {
492
+ mark(td, column);
493
+ column += td.colSpan || 1;
494
+ }
495
+ body.appendChild(totalRow);
496
+ }
497
+ const node = el("table", { class: fold ? (lead >= 0 ? "fold led" : "fold") : "" }, [head, body]);
498
+ if (fold) node.style.setProperty("--keys", String(Math.max(1, keys)));
499
+ return el("div", { class: "scroll" }, [node]);
500
+ }
501
+
502
+ const SVG = "http://www.w3.org/2000/svg";
503
+
504
+ function svgEl(tag, attrs, children) {
505
+ const node = document.createElementNS(SVG, tag);
506
+ for (const [key, value] of Object.entries(attrs || {})) {
507
+ if (key === "text") node.textContent = value;
508
+ else node.setAttribute(key, String(value));
509
+ }
510
+ for (const child of children || []) if (child) node.appendChild(child);
511
+ return node;
138
512
  }
139
513
 
140
- function table(headers, rows, totalRow) {
141
- const head = el("thead", {}, [el("tr", {}, headers.map((h) => el("th", { text: h.label, class: h.numeric ? "num" : "" })))]);
142
- const body = el("tbody", {}, rows.map((row) => el("tr", {}, row.map((value, i) => cell("td", value, headers[i] && headers[i].dataType)))));
143
- if (totalRow) body.appendChild(totalRow);
144
- return el("div", { class: "scroll" }, [el("table", {}, [head, body])]);
514
+ // The chart's geometry in pixels: the plot sits between a line of values on top and a line of labels below.
515
+ const CHART = { height: 172, top: 22, bottom: 26, minSlots: 6, maxColumns: 12 };
516
+ const compactMoney = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1 });
517
+ const compactNumber = new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 });
518
+
519
+ function compactText(value, dataType) {
520
+ if (typeof value !== "number") return BLANK;
521
+ if (dataType === "currency") return compactMoney.format(value);
522
+ if (dataType === "percent") return numberFormat.format(value) + "%";
523
+ return compactNumber.format(value);
524
+ }
525
+
526
+ // The top-level groups of a summary with their value of the measure, in report order. Leaf rows are summed
527
+ // by the first grouping, so this needs a measure that adds up, or a single grouping where the rows are the
528
+ // groups. After maxColumns groups, the rest are summed into one Other column; a measure that does not add
529
+ // up has no Other, so it draws no chart past maxColumns.
530
+ function topGroups(groups, measure) {
531
+ const column = groups.dimensions.length + groups.measures.indexOf(measure);
532
+ const sums = additive(measure);
533
+ if (!sums && groups.dimensions.length !== 1) return null;
534
+ const order = [];
535
+ const values = new Map();
536
+ for (const row of groups.rows) {
537
+ const key = cellText(row[0], "string");
538
+ if (!values.has(key)) {
539
+ order.push(key);
540
+ values.set(key, null);
541
+ }
542
+ const value = row[column];
543
+ if (typeof value === "number") values.set(key, sums ? (values.get(key) || 0) + value : value);
544
+ }
545
+ const all = order.map((label) => ({ label, value: values.get(label) }));
546
+ if (all.length <= CHART.maxColumns) return all;
547
+ if (!sums) return null;
548
+ const rest = all.slice(CHART.maxColumns);
549
+ const other = rest.reduce((a, g) => a + (typeof g.value === "number" ? g.value : 0), 0);
550
+ return [...all.slice(0, CHART.maxColumns), { label: "Other (" + rest.length + " groups)", value: other, other: true }];
551
+ }
552
+
553
+ // Shorten a label with an ellipsis until it fits its slot. The full label stays in the column's title and desc.
554
+ function fitLabel(node, full, room) {
555
+ node.textContent = full;
556
+ if (node.getComputedTextLength() <= room) return;
557
+ for (let n = full.length - 1; n > 0; n--) {
558
+ node.textContent = full.slice(0, n).trimEnd() + "…";
559
+ if (node.getComputedTextLength() <= room) return;
560
+ }
561
+ }
562
+
563
+ let chartObserver = null;
564
+
565
+ // A summary's column chart: one column per top-level group for the primary measure, scaled to the largest.
566
+ // It needs at least two groups and a positive largest value. A negative value draws as an empty column.
567
+ function columnChart(result, measure) {
568
+ const groups = result.groups;
569
+ if (!measure || !groups.dimensions.length || groups.measures.indexOf(measure) < 0) return null;
570
+ const bars = topGroups(groups, measure);
571
+ if (!bars || bars.length < 2) return null;
572
+ const max = Math.max(0, ...bars.map((b) => (typeof b.value === "number" ? b.value : 0)));
573
+ if (max <= 0) return null;
574
+
575
+ const dimension = groups.dimensions[0].label;
576
+ const name = measure.label + " by " + dimension;
577
+ const plot = CHART.height - CHART.top - CHART.bottom;
578
+ const base = CHART.top + plot;
579
+ const slots = Math.max(bars.length, CHART.minSlots);
580
+ const slot = 100 / slots;
581
+ const offset = (100 - bars.length * slot) / 2;
582
+ const round = (n) => Math.round(n * 1000) / 1000;
583
+ const describe = bars.map((b) => b.label + ": " + cellText(b.value, measure.dataType) + ".").join(" ");
584
+
585
+ const svg = svgEl("svg", { class: "chart", role: "img", "aria-labelledby": "chart-title chart-desc", width: "100%", height: CHART.height }, [
586
+ svgEl("title", { id: "chart-title", text: name }),
587
+ svgEl("desc", { id: "chart-desc", text: "Column chart of " + measure.label + " for each " + dimension + ". " + describe }),
588
+ svgEl("line", { class: "base", x1: "0", x2: "100%", y1: base - 0.5, y2: base - 0.5 }),
589
+ ]);
590
+ const labels = [];
591
+ bars.forEach((b, i) => {
592
+ const center = offset + slot * (i + 0.5);
593
+ const height = round(plot * Math.max(0, typeof b.value === "number" ? b.value : 0) / max);
594
+ svg.appendChild(svgEl("rect", {
595
+ class: b.other ? "col other" : "col",
596
+ x: round(center - slot * 0.31) + "%",
597
+ width: round(slot * 0.62) + "%",
598
+ y: round(base - height),
599
+ height,
600
+ rx: 2,
601
+ }, [svgEl("title", { text: b.label + ": " + cellText(b.value, measure.dataType) })]));
602
+ const value = svgEl("text", { class: "value", x: round(center) + "%", y: round(base - height - 6), "text-anchor": "middle", text: compactText(b.value, measure.dataType) });
603
+ const label = svgEl("text", { class: "label", x: round(center) + "%", y: CHART.height - 8, "text-anchor": "middle", text: b.label });
604
+ svg.append(value, label);
605
+ labels.push({ label, value, full: b.label });
606
+ });
607
+
608
+ // Fit the labels to the slot width now and whenever the card changes width. A value that does not fit is hidden.
609
+ const fit = () => {
610
+ const width = svg.getBoundingClientRect().width;
611
+ if (!width) return;
612
+ const room = (width * slot) / 100 - 4;
613
+ for (const item of labels) {
614
+ fitLabel(item.label, item.full, room);
615
+ item.value.setAttribute("visibility", item.value.getComputedTextLength() <= room ? "visible" : "hidden");
616
+ }
617
+ };
618
+ if (typeof ResizeObserver === "function") {
619
+ chartObserver = new ResizeObserver(fit);
620
+ chartObserver.observe(svg);
621
+ }
622
+ return section(name, svg);
145
623
  }
146
624
 
147
625
  function section(title, content) {
@@ -159,7 +637,162 @@ function labelFor(name, measures) {
159
637
  return name === "RowCount" ? "Record Count" : name;
160
638
  }
161
639
 
640
+ // The measures with a data type. Without groups, a summary measure such as s!AMOUNT takes the type of
641
+ // its detail column (AMOUNT); RowCount is a whole number.
642
+ function measureList(result) {
643
+ const groups = result.groups;
644
+ if (groups && groups.measures && groups.measures.length) return groups.measures;
645
+ const columns = (result.detail && result.detail.columns) || [];
646
+ return ((result.totals && result.totals.measures) || []).map((name) => {
647
+ const column = columns.find((c) => c.name === name.slice(name.indexOf("!") + 1));
648
+ const dataType = name === "RowCount" ? "int" : column && NUMERIC.has(column.dataType) ? column.dataType : "number";
649
+ return { name, label: labelFor(name), dataType };
650
+ });
651
+ }
652
+
653
+ // The measure the strip, the bars and the grid follow: the first one that is not the record count.
654
+ function primaryMeasure(measures) {
655
+ return measures.find((m) => m.name !== "RowCount") || measures[0] || null;
656
+ }
657
+
658
+ // A sum or a count adds up across groups; an average, a minimum or a maximum does not.
659
+ function additive(measure) {
660
+ return measure.name === "RowCount" || measure.name.startsWith("s!");
661
+ }
662
+
663
+ function totalOf(result, name) {
664
+ const totals = result.totals;
665
+ if (!totals || !totals.measures) return null;
666
+ const index = totals.measures.indexOf(name);
667
+ const value = index >= 0 ? totals.values[index] : null;
668
+ return typeof value === "number" ? value : null;
669
+ }
670
+
671
+ function kpi(label, value, sub, title) {
672
+ const node = el("div", { class: "kpi" }, [
673
+ el("span", { class: "kpi-label", text: label }),
674
+ el("span", { class: "kpi-value", text: value }),
675
+ sub ? el("span", { class: "kpi-sub", text: sub }) : null,
676
+ ]);
677
+ if (title) node.setAttribute("title", title);
678
+ return node;
679
+ }
680
+
681
+ // The grand total of the primary measure, the record count, and the largest top-level group's share.
682
+ function kpiStrip(result, measures) {
683
+ const items = [];
684
+ const primary = primaryMeasure(measures);
685
+ if (primary && primary.name !== "RowCount") {
686
+ const total = totalOf(result, primary.name);
687
+ if (total !== null) items.push(kpi(primary.label, cellText(total, primary.dataType)));
688
+ }
689
+ const count = totalOf(result, "RowCount");
690
+ const countMeasure = measures.find((m) => m.name === "RowCount");
691
+ if (count !== null) items.push(kpi(countMeasure ? countMeasure.label : labelFor("RowCount"), cellText(count, "int")));
692
+
693
+ const groups = result.groups;
694
+ const shareMeasure = primary && additive(primary) ? primary : countMeasure;
695
+ if (groups && groups.rows && groups.rows.length && groups.dimensions.length && shareMeasure) {
696
+ const column = groups.dimensions.length + groups.measures.indexOf(shareMeasure);
697
+ const sums = new Map();
698
+ for (const row of groups.rows) {
699
+ const value = row[column];
700
+ if (typeof value !== "number") continue;
701
+ const key = cellText(row[0], "string");
702
+ sums.set(key, (sums.get(key) || 0) + value);
703
+ }
704
+ let grand = totalOf(result, shareMeasure.name);
705
+ if (grand === null) grand = [...sums.values()].reduce((a, b) => a + b, 0);
706
+ let top = null;
707
+ for (const [key, value] of sums) if (!top || value > top.value) top = { key, value };
708
+ if (sums.size > 1 && top && top.value > 0 && grand > 0) {
709
+ const share = shareFormat.format(top.value / grand);
710
+ items.push(kpi("Largest " + groups.dimensions[0].label, share, top.key, top.key + " holds " + share + " of " + shareMeasure.label));
711
+ }
712
+ }
713
+ return items.length ? el("section", { class: "kpis", "aria-label": "Key figures" }, items) : null;
714
+ }
715
+
716
+ // Keep the across labels in report order, even when a row lacks some of them.
717
+ function mergeOrder(order, sequence) {
718
+ sequence.forEach((key, i) => {
719
+ if (order.includes(key)) return;
720
+ const next = sequence.slice(i + 1).find((k) => order.includes(k));
721
+ if (next !== undefined) order.splice(order.indexOf(next), 0, key);
722
+ else if (i > 0) order.splice(order.indexOf(sequence[i - 1]) + 1, 0, key);
723
+ else order.push(key);
724
+ });
725
+ }
726
+
727
+ // A grid cell at or above this share of the largest cell is hot: deeper shade, text in the background colour.
728
+ const HOT = 0.6;
729
+
730
+ // A matrix as a real grid: down labels as rows, across labels as columns, the primary measure in the cells,
731
+ // each cell shaded against the largest one, with row and column totals.
732
+ function matrixGrid(result, measure) {
733
+ const groups = result.groups;
734
+ const dims = groups.dimensions;
735
+ const down = dims.map((d, i) => (d.axis === "down" ? i : -1)).filter((i) => i >= 0);
736
+ const across = dims.map((d, i) => (d.axis === "across" ? i : -1)).filter((i) => i >= 0);
737
+ if (!down.length || !across.length || !measure) return null;
738
+ const column = dims.length + groups.measures.indexOf(measure);
739
+ const label = (row, indexes) => indexes.map((i) => cellText(row[i], "string")).join(" / ");
740
+
741
+ const rowKeys = [];
742
+ const colKeys = [];
743
+ const seqs = new Map();
744
+ const values = new Map();
745
+ for (const row of groups.rows) {
746
+ const r = label(row, down);
747
+ const c = label(row, across);
748
+ if (!rowKeys.includes(r)) rowKeys.push(r);
749
+ if (!seqs.has(r)) seqs.set(r, []);
750
+ seqs.get(r).push(c);
751
+ values.set(r + "\u0000" + c, row[column]);
752
+ }
753
+ for (const r of rowKeys) mergeOrder(colKeys, seqs.get(r));
754
+
755
+ let max = 0;
756
+ for (const value of values.values()) if (typeof value === "number" && value > max) max = value;
757
+ const sums = additive(measure);
758
+ const sum = (list) => (sums && list.some((v) => typeof v === "number") ? list.reduce((a, v) => a + (typeof v === "number" ? v : 0), 0) : null);
759
+
760
+ const valueCell = (value) => {
761
+ const td = cell("td", value, measure.dataType);
762
+ if (typeof value === "number" && max > 0) {
763
+ const ratio = Math.max(0, value / max);
764
+ const hot = ratio >= HOT;
765
+ td.classList.add("heat");
766
+ if (hot) td.classList.add("hot");
767
+ td.style.setProperty("--heat", percent(ratio));
768
+ td.style.setProperty("--t", String(Math.round((hot ? (ratio - HOT) / (1 - HOT) : ratio / HOT) * 1000) / 1000));
769
+ }
770
+ return td;
771
+ };
772
+ const head = el("thead", {}, [el("tr", {}, [
773
+ el("th", { text: down.map((i) => dims[i].label).join(" / ") }),
774
+ ...colKeys.map((c) => el("th", { text: c, class: "num" })),
775
+ el("th", { text: "Total", class: "num" }),
776
+ ])]);
777
+ const body = el("tbody", {}, rowKeys.map((r) => {
778
+ const cells = colKeys.map((c) => values.get(r + "\u0000" + c));
779
+ const total = cell("td", sum(cells), measure.dataType);
780
+ total.classList.add("rowtotal");
781
+ return el("tr", {}, [el("th", { text: r, scope: "row" }), ...cells.map(valueCell), total]);
782
+ }));
783
+ const grand = totalOf(result, measure.name);
784
+ const columnTotals = colKeys.map((c) => sum(rowKeys.map((r) => values.get(r + "\u0000" + c))));
785
+ const corner = cell("td", grand !== null ? grand : sum(columnTotals), measure.dataType);
786
+ corner.classList.add("rowtotal");
787
+ body.appendChild(el("tr", { class: "total" }, [el("td", { text: "Total" }), ...columnTotals.map((v) => cell("td", v, measure.dataType)), corner]));
788
+
789
+ const title = measure.label + " by " + down.map((i) => dims[i].label).join(" / ") + " and " + across.map((i) => dims[i].label).join(" / ");
790
+ return section(title, el("div", { class: "scroll grid" }, [el("table", {}, [head, body])]));
791
+ }
792
+
162
793
  function render(result) {
794
+ if (chartObserver) chartObserver.disconnect();
795
+ chartObserver = null;
163
796
  root.replaceChildren();
164
797
  if (!result || !result.report) {
165
798
  root.appendChild(el("p", { class: "empty", text: "This result has no report to show." }));
@@ -179,6 +812,11 @@ function render(result) {
179
812
  const meta = [report.format, report.reportType, report.id ? null : "not saved"].filter(Boolean).join(" · ");
180
813
  root.appendChild(el("div", { class: "head" }, [title, el("span", { class: "meta", text: meta })]));
181
814
 
815
+ const measures = measureList(result);
816
+ const primary = primaryMeasure(measures);
817
+ const strip = kpiStrip(result, measures);
818
+ if (strip) root.appendChild(strip);
819
+
182
820
  if (result.filters && result.filters.length) {
183
821
  root.appendChild(el("ul", { class: "chips", "aria-label": "Filters" }, result.filters.map((f) => el("li", { text: f }))));
184
822
  }
@@ -201,11 +839,32 @@ function render(result) {
201
839
  totalRow.appendChild(cell("td", index >= 0 ? totals.values[index] : null, m.dataType));
202
840
  }
203
841
  }
204
- root.appendChild(section("Groups", table(headers, groups.rows, totalRow)));
842
+ // A matrix leads with its grid; the group table below keeps every measure. Otherwise a column chart of the
843
+ // top-level groups leads, and the rows carry bars.
844
+ const grid = matrixGrid(result, primary);
845
+ if (grid) root.appendChild(grid);
846
+ else if (report.format !== "MATRIX") {
847
+ const chart = columnChart(result, primary);
848
+ if (chart) root.appendChild(chart);
849
+ }
850
+ const lead = primary ? groups.dimensions.length + groups.measures.indexOf(primary) : -1;
851
+ const options = { keys: groups.dimensions.length, lead: lead >= groups.dimensions.length ? lead : -1 };
852
+ if (!grid && options.lead >= 0) {
853
+ const max = Math.max(0, ...groups.rows.map((row) => (typeof row[lead] === "number" ? row[lead] : 0)));
854
+ if (max > 0) options.bar = { column: lead, max };
855
+ }
856
+ const groupTable = table(headers, groups.rows, totalRow, options);
857
+ if (grid) {
858
+ // The grid is the card; the full group table, every measure, is one click away.
859
+ const summary = el("summary", { class: "section-title", text: "All groups (" + groups.rows.length + ")" });
860
+ root.appendChild(el("section", { class: "all-groups" }, [el("details", {}, [summary, groupTable])]));
861
+ } else {
862
+ root.appendChild(section("Groups", groupTable));
863
+ }
205
864
  } else if (totals && totals.values && totals.values.length) {
206
865
  const headers = totals.measures.map((name) => {
207
- const m = ((groups && groups.measures) || []).find((x) => x.name === name);
208
- return { label: labelFor(name, groups && groups.measures), dataType: m ? m.dataType : "number", numeric: true };
866
+ const m = measures.find((x) => x.name === name);
867
+ return { label: m ? m.label : labelFor(name), dataType: m ? m.dataType : "number", numeric: true };
209
868
  });
210
869
  root.appendChild(section("Totals", table(headers, [totals.values])));
211
870
  }