@firestitch/report 18.8.0 → 18.8.1

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.
@@ -32,7 +32,7 @@ import { FsApi, StreamEventType } from '@firestitch/api';
32
32
  import { MatCheckbox } from '@angular/material/checkbox';
33
33
  import * as i3 from '@firestitch/dialog';
34
34
  import { FsDialogModule } from '@firestitch/dialog';
35
- import { parseISO, isValid, format, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, subWeeks, startOfWeek, endOfWeek, addDays, min, max, startOfDay, isLastDayOfMonth, endOfMonth, addMonths, addYears, addQuarters, addWeeks, formatISO } from 'date-fns';
35
+ import { parseISO, isValid, addDays, format, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, subWeeks, startOfWeek, endOfWeek, min, max, startOfDay, isLastDayOfMonth, endOfMonth, addMonths, addYears, addQuarters, addWeeks, formatISO } from 'date-fns';
36
36
  import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
37
37
  import { NgTemplateOutlet } from '@angular/common';
38
38
  import * as i1 from '@firestitch/list';
@@ -1338,6 +1338,34 @@ function formatDayWindow(meta, timezone, locale) {
1338
1338
  const time = formatThroughTime(meta.through, timezone, locale);
1339
1339
  return days && time ? `${days}, until ${time}` : days;
1340
1340
  }
1341
+ // Whether a comparison is read on one day: a comparison of values read at
1342
+ // the end of each period, with no time axis of its own (a KPI, a one-row
1343
+ // list), whose value is read today and whose prior is read at the end of
1344
+ // the period before. Its two windows are those periods, back to back, and
1345
+ // each value is read on its window's last day, so the windows are written as
1346
+ // those days (formatReadingDay). A spine of several periods sets each period
1347
+ // against an earlier one, and its windows are spans like any other
1348
+ // comparison's.
1349
+ function isReadOnOneDay(comparison) {
1350
+ const current = comparison?.current;
1351
+ const prior = comparison?.priors?.[0];
1352
+ if (comparison?.mode !== 'period_end' || !current || !prior) {
1353
+ return false;
1354
+ }
1355
+ const priorEnd = parseDay(prior.end);
1356
+ const currentStart = parseDay(current.start);
1357
+ return !!priorEnd && !!currentStart && addDays(priorEnd, 1).getTime() === currentStart.getTime();
1358
+ }
1359
+ // The day a value is read on: 'on Sep 15, 2026' ('On …' to open a line), as
1360
+ // a chart's tooltip names a prior point read at the end of its period. ''
1361
+ // for a day that is not a real one.
1362
+ function formatReadingDay(window, locale, lead = false) {
1363
+ const day = formatDay(window.end, locale);
1364
+ if (!day) {
1365
+ return '';
1366
+ }
1367
+ return lead ? `On ${day}` : `on ${day}`;
1368
+ }
1341
1369
  // One 'yyyy-MM-dd' day in the locale, written with no zone: 'Sep 1, 2026'.
1342
1370
  function formatDay(day, locale) {
1343
1371
  const date = parseDay(day);
@@ -1695,23 +1723,36 @@ function deltaTone(trend, increaseIs) {
1695
1723
  return rise === (polarity === ReportChangePolarity.Good) ? ReportChangePolarity.Good : ReportChangePolarity.Bad;
1696
1724
  }
1697
1725
  // 'vs $1,139 (Aug 1 – 31, 2026)': the prior value and its window, either
1698
- // alone when the other is missing.
1726
+ // alone when the other is missing. A value read on one day is compared with
1727
+ // the value on the prior window's last day: 'vs 20 (on Aug 31, 2026)', or
1728
+ // 'vs Aug 31, 2026' with no prior value.
1699
1729
  function comparedText(config, data, context) {
1700
1730
  const comparison = data.comparison;
1701
1731
  const prior = comparison.priors?.[0];
1702
- const days = prior ? formatDayWindow(prior, comparison.timezone, context.format.locale) : '';
1703
1732
  const value = data.kpi.prior === null ? '' : formatNumber(data.kpi.prior, config?.measure, context.format);
1733
+ const days = prior ? priorWindowText$1(comparison, prior, context.format.locale, !!value) : '';
1704
1734
  if (value && days) {
1705
1735
  return `vs ${value} (${days})`;
1706
1736
  }
1707
1737
  return value || days ? `vs ${value || days}` : '';
1708
1738
  }
1739
+ // The prior window in the compared text: its days, or the day its value is
1740
+ // read on ('on Aug 31, 2026' beside the value, the bare day without one).
1741
+ function priorWindowText$1(comparison, prior, locale, withValue) {
1742
+ if (!isReadOnOneDay(comparison)) {
1743
+ return formatDayWindow(prior, comparison.timezone, locale);
1744
+ }
1745
+ return withValue ? formatReadingDay(prior, locale) : formatDayRange(prior.end, prior.end, locale);
1746
+ }
1709
1747
  // The days the value covers: the comparison's current window, else the
1710
- // timeframe's. '' when the data has neither.
1748
+ // timeframe's. A value read on one day names that day ('On Sep 15, 2026').
1749
+ // '' when the data has neither.
1711
1750
  function windowLine(data, context) {
1712
1751
  const comparison = data?.comparison;
1713
1752
  if (comparison?.current) {
1714
- return formatDayWindow(comparison.current, comparison.timezone, context.format.locale);
1753
+ return isReadOnOneDay(comparison)
1754
+ ? formatReadingDay(comparison.current, context.format.locale, true)
1755
+ : formatDayWindow(comparison.current, comparison.timezone, context.format.locale);
1715
1756
  }
1716
1757
  return data?.timeframe ? formatDayWindow(data.timeframe, context.timezone, context.format.locale) : '';
1717
1758
  }
@@ -3435,6 +3476,9 @@ const REPORT_WATERFALL_COLORS = {
3435
3476
  fall: REPORT_CHART_COLORS[1],
3436
3477
  total: REPORT_CHART_COLORS[9],
3437
3478
  };
3479
+ // The slate of a tile's small print in both exports: a KPI's label, its
3480
+ // window and target lines, a component's notes.
3481
+ const REPORT_MUTED_TEXT_COLOR = '7b8794';
3438
3482
  // A KPI's change is colored by what it means rather than by its sign: green
3439
3483
  // when it is good news, red when it is bad news, slate when it is neither (a
3440
3484
  // flat change, or a value whose increaseIs is neutral). The same three colors
@@ -3445,8 +3489,10 @@ const REPORT_WATERFALL_COLORS = {
3445
3489
  const REPORT_KPI_CHANGE_COLORS = {
3446
3490
  [ReportChangePolarity.Good]: REPORT_CHART_COLORS[4],
3447
3491
  [ReportChangePolarity.Bad]: REPORT_CHART_COLORS[2],
3448
- [ReportChangePolarity.Neutral]: '7b8794',
3492
+ [ReportChangePolarity.Neutral]: REPORT_MUTED_TEXT_COLOR,
3449
3493
  };
3494
+ // The track a KPI target's progress bar fills, in both exports.
3495
+ const REPORT_KPI_TRACK_COLOR = 'e4e7eb';
3450
3496
  // A KPI's sparkline takes the first series color, as a one-measure chart of
3451
3497
  // the same values would (--report-kpi-sparkline-color overrides it).
3452
3498
  const REPORT_KPI_SPARKLINE_COLOR = REPORT_CHART_COLORS[0];
@@ -4362,7 +4408,7 @@ function seriesDetail(source, index, dataIndex) {
4362
4408
  }
4363
4409
  // A prior value's days. A range comparison compares every point with the
4364
4410
  // same window. A series comparison compares each period with the period its
4365
- // lane's offset back; an as_of comparison reads the value at the end of that
4411
+ // lane's offset back; a period_end comparison reads the value at the end of that
4366
4412
  // period ('on Aug 31, 2026').
4367
4413
  function priorDetail(source, output, dataIndex) {
4368
4414
  const comparison = source.annotations.comparison;
@@ -4382,9 +4428,22 @@ function priorDetail(source, output, dataIndex) {
4382
4428
  if (!window) {
4383
4429
  return '';
4384
4430
  }
4385
- return comparison.mode === 'as_of'
4386
- ? `on ${formatDayRange(window.end, window.end, locale)}`
4387
- : formatDayWindow({ ...window, through: partial ? comparison.current?.through ?? null : null }, comparison.timezone, locale);
4431
+ if (comparison.mode === 'period_end') {
4432
+ return `on ${formatDayRange(window.end, window.end, locale)}`;
4433
+ }
4434
+ return formatDayWindow({ ...withinLaneSpan(window, comparison.priors?.[lane]), through: partial ? comparison.current?.through ?? null : null }, comparison.timezone, locale);
4435
+ }
4436
+ // A series lane reads the span shown moved back by its offset (the server's
4437
+ // comparison.priors[lane]), so the earlier copy of a period the span starts
4438
+ // or ends inside holds only the days within it: a span from Mar 15 sets
4439
+ // March against Feb 15 – 28, not the whole of February.
4440
+ function withinLaneSpan(window, span) {
4441
+ if (!span) {
4442
+ return window;
4443
+ }
4444
+ const start = window.start < span.start ? span.start : window.start;
4445
+ const end = window.end > span.end ? span.end : window.end;
4446
+ return start <= end ? { start, end } : window;
4388
4447
  }
4389
4448
  // The days one period of the axis is compared with: the period moved back by
4390
4449
  // the lane's offset. A period not over yet, compared to date, runs only to
@@ -5403,8 +5462,8 @@ function outputColumnNote(column, data, locale) {
5403
5462
  }
5404
5463
  return [
5405
5464
  comparisonSentence(output, data.comparison, locale),
5406
- rankSentence(output),
5407
- shareSentence(output),
5465
+ rankSentence(output, data.outputs),
5466
+ shareSentence(output, data.outputs),
5408
5467
  windowSentences(data.windows?.[alias], locale, data),
5409
5468
  ]
5410
5469
  .filter((sentence) => !!sentence)
@@ -5422,7 +5481,19 @@ function componentWindowsLine(data, context) {
5422
5481
  }
5423
5482
  return data.timeframe ? formatDayWindow(data.timeframe, context.timezone, context.locale) : '';
5424
5483
  }
5484
+ // Read on one day, the line names the days the values are read on: 'On Sep
5485
+ // 15, 2026 · vs previous month: Aug 31, 2026'.
5425
5486
  function comparisonWindowsLine(comparison, currentWindow, locale) {
5487
+ if (isReadOnOneDay(comparison)) {
5488
+ const priors = (comparison.offsets ?? []).map((offset, index) => {
5489
+ const prior = comparison.priors?.[index];
5490
+ const day = prior ? formatDayRange(prior.end, prior.end, locale) : '';
5491
+ return day ? `${comparisonOffsetLabel(offset)}: ${day}` : comparisonOffsetLabel(offset);
5492
+ });
5493
+ return [formatReadingDay(currentWindow, locale, true), ...priors]
5494
+ .filter((part) => !!part)
5495
+ .join(WINDOWS_SEPARATOR);
5496
+ }
5426
5497
  const current = formatDayWindow(currentWindow, comparison.timezone, locale);
5427
5498
  const priors = (comparison.offsets ?? []).map((offset, index) => {
5428
5499
  return priorWindowText(offset, comparison.priors?.[index], comparison.timezone, locale);
@@ -5459,8 +5530,9 @@ function windowNotes(data, locale) {
5459
5530
  .flatMap((meta) => (meta.notes ?? []).map((code) => windowNoteText(code, meta, locale, data.granularity)));
5460
5531
  }
5461
5532
  // A compared column's windows. In range mode every row compares the same two
5462
- // windows, so they are named; in series and as-of modes each row compares its
5463
- // own period, so only the lane is.
5533
+ // windows, so they are named, and read on one day the days the values are
5534
+ // read on are; over a series or a spine of periods each row compares its own
5535
+ // period, so only the lane is.
5464
5536
  function comparisonSentence(output, comparison, locale) {
5465
5537
  if (!output.role || !comparison) {
5466
5538
  return '';
@@ -5472,6 +5544,9 @@ function comparisonSentence(output, comparison, locale) {
5472
5544
  if (comparison.mode === 'range' && current && (output.role === 'current' || prior)) {
5473
5545
  return rangeSentence(output.role, current, prior, offset);
5474
5546
  }
5547
+ if (isReadOnOneDay(comparison) && comparison.current && priorWindow) {
5548
+ return readingDaySentence(output.role, comparison.current, priorWindow, offset, locale);
5549
+ }
5475
5550
  return output.role === 'current' || !offset ? '' : `${ROLE_LEADS[output.role]} ${comparisonOffsetLabel(offset)}.`;
5476
5551
  }
5477
5552
  function rangeSentence(role, current, prior, offset) {
@@ -5484,9 +5559,25 @@ function rangeSentence(role, current, prior, offset) {
5484
5559
  return `${ROLE_LEADS[role]} from ${prior} to ${current}.`;
5485
5560
  }
5486
5561
  }
5562
+ // A column of a comparison read on one day, named by the days its values are
5563
+ // read on: 'Prior value on Aug 31, 2026 (vs previous month).'
5564
+ function readingDaySentence(role, currentWindow, priorWindow, offset, locale) {
5565
+ const current = formatReadingDay(currentWindow, locale);
5566
+ const prior = formatReadingDay(priorWindow, locale);
5567
+ switch (role) {
5568
+ case 'current':
5569
+ return `Value ${current}.`;
5570
+ case 'prior':
5571
+ return offset ? `Prior value ${prior} (${comparisonOffsetLabel(offset)}).` : `Prior value ${prior}.`;
5572
+ default:
5573
+ return `${ROLE_LEADS[role]} from the value ${prior} to the value ${current}.`;
5574
+ }
5575
+ }
5487
5576
  // 'Rank by Revenue, 1 being the largest, within each Region. Tied values share
5488
- // a rank (1, 1, 3).'
5489
- function rankSentence(output) {
5577
+ // a rank (1, 1, 3).' A measure written as its own rank ranks by the value it
5578
+ // would otherwise show, so it names no other measure ('Rank, 1 being the
5579
+ // largest, across all rows.').
5580
+ function rankSentence(output, outputs) {
5490
5581
  const rank = output.kind === 'rank' ? output.rank : null;
5491
5582
  if (!rank) {
5492
5583
  return '';
@@ -5494,18 +5585,32 @@ function rankSentence(output) {
5494
5585
  const end = rank.direction === 'asc' ? 'smallest' : 'largest';
5495
5586
  const scope = rank.within?.length ? `within each ${joinNames(rank.within)}` : 'across all rows';
5496
5587
  const ties = rank.method === 'dense' ? '1, 1, 2' : '1, 1, 3';
5497
- return `Rank by ${output.measure}, 1 being the ${end}, ${scope}. Tied values share a rank (${ties}). `
5588
+ const by = isOwnOutput(output, outputs) ? '' : ` by ${output.measure}`;
5589
+ return `Rank${by}, 1 being the ${end}, ${scope}. Tied values share a rank (${ties}). `
5498
5590
  + FILTERED_ROWS_SENTENCE;
5499
5591
  }
5500
- // 'Share of the total Revenue within each Region.'
5501
- function shareSentence(output) {
5592
+ // 'Share of the total Revenue within each Region.' A measure written as its
5593
+ // own share names no other measure ('Share of the total within each
5594
+ // Region.', 'Share of the total across all rows.').
5595
+ function shareSentence(output, outputs) {
5502
5596
  const share = output.kind === 'share' ? output.share : null;
5503
5597
  if (!share) {
5504
5598
  return '';
5505
5599
  }
5600
+ if (isOwnOutput(output, outputs)) {
5601
+ const scope = share.within?.length ? `within each ${joinNames(share.within)}` : 'across all rows';
5602
+ return `Share of the total ${scope}. ${FILTERED_ROWS_SENTENCE}`;
5603
+ }
5506
5604
  const scope = share.within?.length ? ` within each ${joinNames(share.within)}` : '';
5507
5605
  return `Share of the total ${output.measure}${scope}. ${FILTERED_ROWS_SENTENCE}`;
5508
5606
  }
5607
+ // Whether a rank or a share is its measure's own output: the server writes a
5608
+ // measure with a rank or a share as that rank or share, so its measure is a
5609
+ // column of the same kind (itself, or for a prior or a change the current
5610
+ // column), never a value of its own to name.
5611
+ function isOwnOutput(output, outputs) {
5612
+ return outputs?.[output.measure]?.kind === output.kind;
5613
+ }
5509
5614
  // A windowed column: what it is, then why it reads as it does.
5510
5615
  function windowSentences(meta, locale, data) {
5511
5616
  if (!meta) {
@@ -8105,6 +8210,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
8105
8210
  ], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <!-- One skeleton over the whole body until the locale list is in. -->\n <ng-container *fsSkeletonForm=\"ready() || null\">\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n </div>\n <!--\n How deep the header and footer bands are. Their content is edited\n on the canvas (Edit layout \u2192 Header & footer); these reserve the\n space for it, and 0 means no band.\n -->\n <mat-form-field class=\"full-width\">\n <mat-label>\n Header height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"headerHeight\"\n name=\"headerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Footer height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"footerHeight\"\n name=\"footerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n <!--\n How numbers and dates are written (formatting only: no text is\n translated) and which currency money is labeled in. Hidden on a\n server too old to resolve a report's locale.\n -->\n @if (locales()) {\n <mat-form-field class=\"full-width\">\n <mat-label>\n Locale\n </mat-label>\n <mat-select\n [ngModel]=\"locale()\"\n (ngModelChange)=\"locale.set($event)\"\n name=\"locale\">\n @for (option of localeOptions(); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }}\n </mat-option>\n }\n </mat-select>\n <mat-hint>\n {{ formatExample() }}\n </mat-hint>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Currency\n </mat-label>\n <mat-select\n [ngModel]=\"currency()\"\n (ngModelChange)=\"currency.set($event)\"\n name=\"currency\">\n @for (option of currencyOptions(); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }}\n </mat-option>\n }\n </mat-select>\n <mat-hint>\n Names the currency amounts are in; it does not convert them.\n </mat-hint>\n </mat-form-field>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n <!-- A stored rolling preset cannot be authored here, only\n kept, replaced by picking dates, or cleared. type=\"button\"\n keeps Clear from submitting the form. -->\n @if (row.relative && !row.start && !row.end) {\n <div class=\"fs-row.gap-sm.align-center\">\n <mat-hint class=\"fs-flex\">\n Rolling default: {{ relativeLabel(row.relative) }}. Pick dates to replace it.\n </mat-hint>\n <button\n mat-button\n type=\"button\"\n (click)=\"clearRelative(row)\">\n Clear\n </button>\n </div>\n }\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </ng-container>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"] }]
8106
8211
  }] });
8107
8212
 
8213
+ // How far a KPI card's rows shrink to fit a short tile. A tile shorter still
8214
+ // lets the card run past its foot rather than print it unreadably small.
8215
+ const KPI_EXPORT_MIN_SCALE = 0.5;
8108
8216
  // How many tile queries an export runs at once.
8109
8217
  //
8110
8218
  // Not a politeness setting — it is the difference between a deck and a broken
@@ -8264,7 +8372,9 @@ function kpiCardHasExtras(view, notes) {
8264
8372
  // sparkline first, then the target line, then the notes; the value, its label
8265
8373
  // and the delta row always stay, and the window line stays whenever the card
8266
8374
  // shows a comparison (its days are what the delta is measured over). The
8267
- // sparkline needs values to draw.
8375
+ // sparkline needs values to draw. When what stays is still too tall, every
8376
+ // row shrinks by the same scale, down to KPI_EXPORT_MIN_SCALE; by then the
8377
+ // sparkline, the target and the notes are gone.
8268
8378
  function kpiExportParts(view, notes, available, heights) {
8269
8379
  const parts = {
8270
8380
  delta: !!view.delta,
@@ -8272,6 +8382,7 @@ function kpiExportParts(view, notes, available, heights) {
8272
8382
  target: !!view.target,
8273
8383
  sparkline: !!view.sparkline?.values.some((value) => value !== null),
8274
8384
  notes: [...notes],
8385
+ scale: 1,
8275
8386
  };
8276
8387
  const height = () => kpiExportHeight(view, parts, heights);
8277
8388
  if (height() > available) {
@@ -8286,8 +8397,24 @@ function kpiExportParts(view, notes, available, heights) {
8286
8397
  if (height() > available && !parts.delta) {
8287
8398
  parts.window = false;
8288
8399
  }
8400
+ if (height() > available) {
8401
+ parts.scale = Math.max(KPI_EXPORT_MIN_SCALE, Math.max(0, available) / height());
8402
+ }
8289
8403
  return parts;
8290
8404
  }
8405
+ // The heights scaled by the parts' scale: what the renderer lays the card
8406
+ // out with, its text sized by the same scale.
8407
+ function scaledKpiHeights(heights, scale) {
8408
+ return {
8409
+ value: heights.value * scale,
8410
+ label: heights.label * scale,
8411
+ delta: heights.delta * scale,
8412
+ window: heights.window * scale,
8413
+ target: heights.target * scale,
8414
+ sparkline: heights.sparkline * scale,
8415
+ note: heights.note * scale,
8416
+ };
8417
+ }
8291
8418
  // How tall the card's drawn parts stand together, in the heights' unit.
8292
8419
  function kpiExportHeight(view, parts, heights) {
8293
8420
  return heights.value
@@ -8485,7 +8612,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
8485
8612
  // in the report's format context, as on screen.
8486
8613
  async function renderChartImage(component, data, width, height, context, pixelRatio = 3) {
8487
8614
  // The same tree-shaken echarts build + 'report' theme the live canvas uses.
8488
- const echarts = (await import('./firestitch-report-echarts-qCBE3LqM.mjs')).default;
8615
+ const echarts = (await import('./firestitch-report-echarts-CFnbJx44.mjs')).default;
8489
8616
  const host = document.createElement('div');
8490
8617
  host.style.width = `${width}px`;
8491
8618
  host.style.height = `${height}px`;
@@ -8522,7 +8649,7 @@ const SMALL_SIZE = 7.5;
8522
8649
  const SUBTITLE_H$1 = 11;
8523
8650
  const NOTE_LINE_H$1 = 10;
8524
8651
  const MIN_BODY_H$1 = 40;
8525
- const MUTED_COLOR$1 = '#7b8794';
8652
+ const MUTED_COLOR$1 = `#${REPORT_MUTED_TEXT_COLOR}`;
8526
8653
  // A KPI card's parts, top to bottom, points (kpiExportParts sheds the
8527
8654
  // sparkline, then the target, then the notes on a short tile). The value's
8528
8655
  // baseline sits KPI_VALUE_BASELINE under its top.
@@ -8543,7 +8670,7 @@ const KPI_ARROW_GAP$1 = 3;
8543
8670
  // The target's progress bar, and the track it fills.
8544
8671
  const KPI_BAR_W$1 = 120;
8545
8672
  const KPI_BAR_H$1 = 3;
8546
- const KPI_TRACK_COLOR$1 = '#e4e7eb';
8673
+ const KPI_TRACK_COLOR$1 = `#${REPORT_KPI_TRACK_COLOR}`;
8547
8674
  // The sparkline's widest box, its top and bottom inset, and its dots' radius.
8548
8675
  const KPI_SPARKLINE_W$1 = 140;
8549
8676
  const KPI_SPARKLINE_INSET = 3;
@@ -8835,12 +8962,12 @@ class ReportPdfService {
8835
8962
  }
8836
8963
  // One line of small grey print, cut to the width with an ellipsis: a
8837
8964
  // subtitle, a note or a line of a KPI card. y is the line's top; the line is
8838
- // NOTE_LINE_H tall.
8839
- _smallLine(doc, text, x, y, w, align, style) {
8965
+ // NOTE_LINE_H tall, times the scale a short KPI tile shrinks its lines by.
8966
+ _smallLine(doc, text, x, y, w, align, style, scale = 1) {
8840
8967
  doc.setFont('helvetica', style);
8841
- doc.setFontSize(SMALL_SIZE);
8968
+ doc.setFontSize(SMALL_SIZE * scale);
8842
8969
  doc.setTextColor(MUTED_COLOR$1);
8843
- doc.text(this._fit(doc, text, w), align === 'center' ? x + w / 2 : x, y + SMALL_SIZE, { align });
8970
+ doc.text(this._fit(doc, text, w), align === 'center' ? x + w / 2 : x, y + SMALL_SIZE * scale, { align });
8844
8971
  }
8845
8972
  // The text, ellipsized to the width in the doc's current font.
8846
8973
  _fit(doc, text, width) {
@@ -8865,7 +8992,7 @@ class ReportPdfService {
8865
8992
  _boxNote(doc, text, x, y, w, h) {
8866
8993
  doc.setFont('helvetica', 'italic');
8867
8994
  doc.setFontSize(9);
8868
- doc.setTextColor('#7b8794');
8995
+ doc.setTextColor(MUTED_COLOR$1);
8869
8996
  doc.text(text, x + w / 2, y + h / 2, { align: 'center' });
8870
8997
  }
8871
8998
  // A list's on-page table. All of its fetched rows are laid out once to learn
@@ -8963,7 +9090,7 @@ class ReportPdfService {
8963
9090
  // takes columnStyles[0], right-aligned when that column is numeric.
8964
9091
  styles: {
8965
9092
  fontStyle: 'italic',
8966
- textColor: '#7b8794',
9093
+ textColor: MUTED_COLOR$1,
8967
9094
  halign: 'left',
8968
9095
  ...(singleLine ? { overflow: 'ellipsize' } : {}),
8969
9096
  },
@@ -9075,69 +9202,73 @@ class ReportPdfService {
9075
9202
  if (view.label) {
9076
9203
  doc.setFont('helvetica', 'normal');
9077
9204
  doc.setFontSize(9);
9078
- doc.setTextColor('#7b8794');
9205
+ doc.setTextColor(MUTED_COLOR$1);
9079
9206
  doc.text(view.label, x + w / 2, y + h / 2 + 16, { align: 'center' });
9080
9207
  }
9081
9208
  return;
9082
9209
  }
9210
+ // A tile too short for the card's lines shrinks them all alike.
9083
9211
  const parts = kpiExportParts(view, notes, h, KPI_HEIGHTS$1);
9084
- let top = y + Math.max(0, (h - kpiExportHeight(view, parts, KPI_HEIGHTS$1)) / 2);
9212
+ const heights = scaledKpiHeights(KPI_HEIGHTS$1, parts.scale);
9213
+ let top = y + Math.max(0, (h - kpiExportHeight(view, parts, heights)) / 2);
9085
9214
  doc.setFont('helvetica', 'bold');
9086
- doc.setFontSize(26);
9215
+ doc.setFontSize(26 * parts.scale);
9087
9216
  doc.setTextColor('#1f2933');
9088
- doc.text(value, x + w / 2, top + KPI_VALUE_BASELINE, { align: 'center' });
9089
- top += KPI_HEIGHTS$1.value;
9217
+ doc.text(value, x + w / 2, top + KPI_VALUE_BASELINE * parts.scale, { align: 'center' });
9218
+ top += heights.value;
9090
9219
  if (view.label) {
9091
9220
  doc.setFont('helvetica', 'normal');
9092
- doc.setFontSize(9);
9221
+ doc.setFontSize(9 * parts.scale);
9093
9222
  doc.setTextColor(MUTED_COLOR$1);
9094
- doc.text(this._fit(doc, this._pdfText(view.label), w), x + w / 2, top + 9, { align: 'center' });
9095
- top += KPI_HEIGHTS$1.label;
9223
+ doc.text(this._fit(doc, this._pdfText(view.label), w), x + w / 2, top + 9 * parts.scale, { align: 'center' });
9224
+ top += heights.label;
9096
9225
  }
9097
9226
  if (parts.delta && view.delta) {
9098
- this._kpiDelta(doc, view.delta, x, top, w);
9099
- top += KPI_HEIGHTS$1.delta;
9227
+ this._kpiDelta(doc, view.delta, x, top, w, parts.scale);
9228
+ top += heights.delta;
9100
9229
  }
9101
9230
  if (parts.window) {
9102
- this._smallLine(doc, this._pdfText(view.window), x, top, w, 'center', 'normal');
9103
- top += KPI_HEIGHTS$1.window;
9231
+ this._smallLine(doc, this._pdfText(view.window), x, top, w, 'center', 'normal', parts.scale);
9232
+ top += heights.window;
9104
9233
  }
9105
9234
  if (parts.target && view.target) {
9106
9235
  this._kpiTarget(doc, view.target, x, top, w);
9107
- top += KPI_HEIGHTS$1.target;
9236
+ top += heights.target;
9108
9237
  }
9109
9238
  if (parts.sparkline && view.sparkline) {
9110
9239
  this._kpiSparkline(doc, view.sparkline.values, view.sparkline.partial, x, top, w);
9111
- top += KPI_HEIGHTS$1.sparkline;
9240
+ top += heights.sparkline;
9112
9241
  }
9113
9242
  for (const line of parts.notes) {
9114
9243
  this._smallLine(doc, this._pdfText(line), x, top, w, 'center', 'italic');
9115
- top += KPI_HEIGHTS$1.note;
9244
+ top += heights.note;
9116
9245
  }
9117
9246
  }
9118
9247
  // The delta row, centered as one group: a triangle pointing the way the
9119
9248
  // value moved (none when the shown change rounds to zero) and the change in
9120
9249
  // the color of what it means, then what it is compared with in grey.
9121
- _kpiDelta(doc, delta, x, top, w) {
9250
+ _kpiDelta(doc, delta, x, top, w, scale = 1) {
9122
9251
  const color = `#${REPORT_KPI_CHANGE_COLORS[delta.tone]}`;
9123
9252
  const change = this._pdfText(delta.text);
9124
- const arrow = delta.trend === ReportKpiTrend.Flat ? 0 : KPI_ARROW$1 + KPI_ARROW_GAP$1;
9253
+ const size = KPI_DELTA_SIZE * scale;
9254
+ const side = KPI_ARROW$1 * scale;
9255
+ const arrow = delta.trend === ReportKpiTrend.Flat ? 0 : side + KPI_ARROW_GAP$1 * scale;
9125
9256
  doc.setFont('helvetica', 'bold');
9126
- doc.setFontSize(KPI_DELTA_SIZE);
9257
+ doc.setFontSize(size);
9127
9258
  const changeWidth = doc.getTextWidth(change);
9128
9259
  doc.setFont('helvetica', 'normal');
9129
9260
  const compared = delta.compared
9130
9261
  ? this._fit(doc, ` ${this._pdfText(delta.compared)}`, Math.max(0, w - arrow - changeWidth))
9131
9262
  : '';
9132
9263
  const width = arrow + changeWidth + (compared ? doc.getTextWidth(compared) : 0);
9133
- const baseline = top + KPI_DELTA_SIZE;
9264
+ const baseline = top + size;
9134
9265
  let left = x + Math.max(0, (w - width) / 2);
9135
9266
  if (arrow) {
9136
9267
  const up = delta.trend === ReportKpiTrend.Up;
9137
- const tip = up ? baseline - KPI_ARROW$1 : baseline;
9138
- const base = up ? baseline : baseline - KPI_ARROW$1;
9268
+ const tip = up ? baseline - side : baseline;
9269
+ const base = up ? baseline : baseline - side;
9139
9270
  doc.setFillColor(color);
9140
- doc.triangle(left, base, left + KPI_ARROW$1, base, left + KPI_ARROW$1 / 2, tip, 'F');
9271
+ doc.triangle(left, base, left + side, base, left + side / 2, tip, 'F');
9141
9272
  left += arrow;
9142
9273
  }
9143
9274
  doc.setFont('helvetica', 'bold');
@@ -9245,7 +9376,7 @@ const SMALL_FONT_SIZE = 8;
9245
9376
  const SUBTITLE_H = 0.18;
9246
9377
  const NOTE_LINE_H = 0.16;
9247
9378
  const MIN_BODY_H = 0.6;
9248
- const MUTED_COLOR = '7B8794';
9379
+ const MUTED_COLOR = REPORT_MUTED_TEXT_COLOR;
9249
9380
  // A KPI card's parts, top to bottom, inches (kpiExportParts sheds the
9250
9381
  // sparkline, then the target, then the notes on a short tile).
9251
9382
  const KPI_TARGET_TEXT_H = 0.18;
@@ -9270,7 +9401,7 @@ const KPI_ARROW_GAP = 0.04;
9270
9401
  const PPTX_CHAR_WIDTH = 0.42;
9271
9402
  // The target's progress bar, and the track it fills.
9272
9403
  const KPI_BAR_W = 1.7;
9273
- const KPI_TRACK_COLOR = 'E4E7EB';
9404
+ const KPI_TRACK_COLOR = REPORT_KPI_TRACK_COLOR;
9274
9405
  // The sparkline's widest box, and its series name in the data sheet when the
9275
9406
  // measure has no label.
9276
9407
  const KPI_SPARKLINE_W = 2;
@@ -9500,7 +9631,7 @@ class ReportPptxService {
9500
9631
  if (!item.data || !item.data.rows?.length) {
9501
9632
  slide.addText(emptyLabel(item), {
9502
9633
  x, y: bodyY, w, h: bodyH,
9503
- fontSize: 10, italic: true, color: '7B8794', align: 'center', valign: 'middle',
9634
+ fontSize: 10, italic: true, color: MUTED_COLOR, align: 'center', valign: 'middle',
9504
9635
  });
9505
9636
  return;
9506
9637
  }
@@ -10085,7 +10216,7 @@ class ReportPptxService {
10085
10216
  if (remaining > 0) {
10086
10217
  body.push([{
10087
10218
  text: moreRowsNotice(remaining),
10088
- options: { colspan: columns.length, italic: true, color: '7B8794' },
10219
+ options: { colspan: columns.length, italic: true, color: MUTED_COLOR },
10089
10220
  }]);
10090
10221
  }
10091
10222
  // The label is interface text, so it keeps the default proofing language.
@@ -10120,7 +10251,7 @@ class ReportPptxService {
10120
10251
  text: view.value,
10121
10252
  options: pptxRunOptions({ fontSize: 28, bold: true, color: '1F2933', breakLine: true }, context.locale),
10122
10253
  },
10123
- { text: view.label, options: { fontSize: 10, color: '7B8794' } },
10254
+ { text: view.label, options: { fontSize: 10, color: MUTED_COLOR } },
10124
10255
  ], {
10125
10256
  x, y, w, h,
10126
10257
  align: 'center',
@@ -10128,8 +10259,11 @@ class ReportPptxService {
10128
10259
  });
10129
10260
  return;
10130
10261
  }
10262
+ // A tile too short for the card's lines shrinks them all alike.
10131
10263
  const parts = kpiExportParts(view, notes, h, KPI_HEIGHTS);
10132
- let top = y + Math.max(0, (h - kpiExportHeight(view, parts, KPI_HEIGHTS)) / 2);
10264
+ const heights = scaledKpiHeights(KPI_HEIGHTS, parts.scale);
10265
+ const lineFontSize = KPI_LINE_FONT_SIZE * parts.scale;
10266
+ let top = y + Math.max(0, (h - kpiExportHeight(view, parts, heights)) / 2);
10133
10267
  const line = (runs, rowH, options) => {
10134
10268
  slide.addText(runs, {
10135
10269
  x, y: top, w, h: rowH,
@@ -10137,31 +10271,31 @@ class ReportPptxService {
10137
10271
  });
10138
10272
  top += rowH;
10139
10273
  };
10140
- line([{ text: view.value, options: pptxRunOptions({ bold: true, color: '1F2933' }, context.locale) }], KPI_HEIGHTS.value, { fontSize: 28 });
10274
+ line([{ text: view.value, options: pptxRunOptions({ bold: true, color: '1F2933' }, context.locale) }], heights.value, { fontSize: 28 * parts.scale });
10141
10275
  if (view.label) {
10142
- line(view.label, KPI_HEIGHTS.label, { fontSize: 10, color: MUTED_COLOR });
10276
+ line(view.label, heights.label, { fontSize: 10 * parts.scale, color: MUTED_COLOR });
10143
10277
  }
10144
10278
  if (parts.delta && view.delta) {
10145
- this._kpiDelta(pptx, slide, view.delta, context, x, top, w);
10146
- top += KPI_HEIGHTS.delta;
10279
+ this._kpiDelta(pptx, slide, view.delta, context, x, top, w, parts.scale);
10280
+ top += heights.delta;
10147
10281
  }
10148
10282
  if (parts.window) {
10149
- const days = this._fitEstimate(view.window, KPI_LINE_FONT_SIZE, w);
10150
- line([{ text: days, options: pptxRunOptions({}, context.locale) }], KPI_HEIGHTS.window, {
10151
- fontSize: KPI_LINE_FONT_SIZE, color: MUTED_COLOR,
10283
+ const days = this._fitEstimate(view.window, lineFontSize, w);
10284
+ line([{ text: days, options: pptxRunOptions({}, context.locale) }], heights.window, {
10285
+ fontSize: lineFontSize, color: MUTED_COLOR,
10152
10286
  });
10153
10287
  }
10154
10288
  if (parts.target && view.target) {
10155
10289
  this._kpiTarget(pptx, slide, view.target, x, top, w);
10156
- top += KPI_HEIGHTS.target;
10290
+ top += heights.target;
10157
10291
  }
10158
10292
  if (parts.sparkline && view.sparkline) {
10159
10293
  this._kpiSparkline(pptx, slide, item, view.sparkline.values, view.label, x, top, w);
10160
- top += KPI_HEIGHTS.sparkline;
10294
+ top += heights.sparkline;
10161
10295
  }
10162
10296
  if (parts.notes.length) {
10163
10297
  const fitted = parts.notes.map((note) => this._fitEstimate(note, SMALL_FONT_SIZE, w));
10164
- line(this._lineRuns(fitted, { italic: true }), parts.notes.length * KPI_HEIGHTS.note, {
10298
+ line(this._lineRuns(fitted, { italic: true }), parts.notes.length * heights.note, {
10165
10299
  fontSize: SMALL_FONT_SIZE, color: MUTED_COLOR,
10166
10300
  });
10167
10301
  }
@@ -10171,28 +10305,31 @@ class ReportPptxService {
10171
10305
  // it pointing the way the value moved (none when the shown change rounds to
10172
10306
  // zero). PowerPoint lays the text out itself, so the triangle sits where
10173
10307
  // the text's estimated width puts its start.
10174
- _kpiDelta(pptx, slide, delta, context, x, top, w) {
10308
+ _kpiDelta(pptx, slide, delta, context, x, top, w, scale = 1) {
10175
10309
  const color = REPORT_KPI_CHANGE_COLORS[delta.tone];
10176
- const arrow = delta.trend === ReportKpiTrend.Flat ? 0 : KPI_ARROW + KPI_ARROW_GAP;
10177
- const changeW = this._estimatedWidth(delta.text, KPI_LINE_FONT_SIZE);
10310
+ const fontSize = KPI_LINE_FONT_SIZE * scale;
10311
+ const rowH = KPI_HEIGHTS.delta * scale;
10312
+ const side = KPI_ARROW * scale;
10313
+ const arrow = delta.trend === ReportKpiTrend.Flat ? 0 : side + KPI_ARROW_GAP * scale;
10314
+ const changeW = this._estimatedWidth(delta.text, fontSize);
10178
10315
  const compared = delta.compared
10179
- ? this._fitEstimate(` ${delta.compared}`, KPI_LINE_FONT_SIZE, w - arrow - changeW)
10316
+ ? this._fitEstimate(` ${delta.compared}`, fontSize, w - arrow - changeW)
10180
10317
  : '';
10181
- const textW = Math.min(w - arrow, changeW + this._estimatedWidth(compared, KPI_LINE_FONT_SIZE));
10318
+ const textW = Math.min(w - arrow, changeW + this._estimatedWidth(compared, fontSize));
10182
10319
  const runs = [
10183
10320
  { text: delta.text, options: pptxRunOptions({ bold: true, color }, context.locale) },
10184
10321
  ...(compared ? [{ text: compared, options: { color: MUTED_COLOR } }] : []),
10185
10322
  ];
10186
10323
  slide.addText(runs, {
10187
- x: x + arrow, y: top, w: Math.max(0.1, w - arrow), h: KPI_HEIGHTS.delta,
10188
- fontSize: KPI_LINE_FONT_SIZE, align: 'center', valign: 'middle', margin: 0, fit: 'none',
10324
+ x: x + arrow, y: top, w: Math.max(0.1, w - arrow), h: rowH,
10325
+ fontSize, align: 'center', valign: 'middle', margin: 0, fit: 'none',
10189
10326
  });
10190
10327
  if (arrow) {
10191
10328
  slide.addShape(pptx.ShapeType.triangle, {
10192
10329
  x: Math.max(x, x + (w - arrow - textW) / 2),
10193
- y: top + (KPI_HEIGHTS.delta - KPI_ARROW) / 2,
10194
- w: KPI_ARROW,
10195
- h: KPI_ARROW,
10330
+ y: top + (rowH - side) / 2,
10331
+ w: side,
10332
+ h: side,
10196
10333
  fill: { color },
10197
10334
  line: { color, width: 0 },
10198
10335
  flipV: delta.trend === ReportKpiTrend.Down,
@@ -10238,8 +10375,8 @@ class ReportPptxService {
10238
10375
  // The sparkline as a native line chart with its axes, gridlines and legend
10239
10376
  // hidden, scaled from its lowest value to its highest as on screen, its
10240
10377
  // periods and values in the chart's data sheet. A gap stays a gap. Markers
10241
- // show only when a value stands alone between gaps, which a line cannot
10242
- // draw.
10378
+ // show only when a value stands alone between gaps (the last one too),
10379
+ // which a line cannot draw.
10243
10380
  _kpiSparkline(pptx, slide, item, values, label, x, top, w) {
10244
10381
  const numbers = values.filter((value) => value !== null);
10245
10382
  const low = Math.min(...numbers);
@@ -10247,6 +10384,10 @@ class ReportPptxService {
10247
10384
  const pad = high === low ? Math.max(1, Math.abs(high)) : 0;
10248
10385
  const boxW = Math.min(w * 0.7, KPI_SPARKLINE_W);
10249
10386
  const geometry = kpiSparklineGeometry(values, false);
10387
+ // kpiSparklineGeometry keeps the last point out of the dots, as the
10388
+ // screen marks it apart; a last value after a gap is a lone point too.
10389
+ const lastStandsAlone = values[values.length - 1] !== null
10390
+ && (values.length === 1 || values[values.length - 2] === null);
10250
10391
  const periods = item.data?.kpi?.sparkline?.points?.map((point) => point.period) ?? [];
10251
10392
  const color = REPORT_KPI_SPARKLINE_COLOR;
10252
10393
  this._addChart(slide, { line: pptx.ChartType.line }, [{
@@ -10256,7 +10397,7 @@ class ReportPptxService {
10256
10397
  options: {
10257
10398
  chartColors: [color],
10258
10399
  lineSize: 1.5,
10259
- lineDataSymbol: geometry.dots.length ? 'circle' : 'none',
10400
+ lineDataSymbol: geometry.dots.length || lastStandsAlone ? 'circle' : 'none',
10260
10401
  lineDataSymbolSize: 4,
10261
10402
  },
10262
10403
  }], [{
@@ -10762,7 +10903,7 @@ class ReportComponent {
10762
10903
  ReportExportService,
10763
10904
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
10764
10905
  // this route's chunk.
10765
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-qCBE3LqM.mjs').then((module) => module.default) }),
10906
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CFnbJx44.mjs').then((module) => module.default) }),
10766
10907
  ], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div\n #split\n class=\"report fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n style=\"min-height: 500px;\"\n basePath=\"reports\"\n [requestData]=\"{ reportId: selected()?.id ?? null }\"\n [introMessage]=\"introMessage\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n <div class=\"viewer fs-flex fs-column\">\n <div class=\"fs-row.align-center.gap-sm\">\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [fetch]=\"fetchReports\"\n [(ngModel)]=\"selected\"\n [disabled]=\"loadingReports()\"\n [padless]=\"true\"\n [multiple]=\"false\"\n [fetchOnFocus]=\"true\"\n (ngModelChange)=\"reportChange($event)\"\n placeholder=\"Report\"\n name=\"report\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n <ng-template\n fsAutocompleteChipsStatic\n (click)=\"createReport()\">\n Create Report\n </ng-template>\n </fs-autocomplete-chips>\n @if (report() && editMode()) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n @if (report()) {\n <fs-menu>\n <ng-template\n fs-menu-item\n (click)=\"reportSettings()\">\n <mat-icon>\n tune\n </mat-icon>\n Report settings\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleEditMode()\"\n [disabled]=\"editMode()\">\n <mat-icon>\n dashboard_customize\n </mat-icon>\n {{ editMode() ? 'Done editing layout' : 'Edit layout' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n <mat-icon>\n slideshow\n </mat-icon>\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n <mat-icon>\n picture_as_pdf\n </mat-icon>\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data\n </ng-template>\n @if ((report()?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n }\n </div>\n @if (report()) {\n <!-- Report-level filters only (the report's actions live in the menu\n above). fs-filter reads its config once at init, so it's keyed on the\n filter signature: when the report-level filter set changes the block\n is recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter [config]=\"reportFilterConfig()\"></fs-filter>\n }\n }\n <app-report-canvas\n [report]=\"report()\"\n [editMode]=\"editMode()\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\">\n </app-report-canvas>\n }\n </div>\n</div>", styles: [".report{height:100%;min-height:500px}.report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:500px;min-width:0;border:none}.report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.report .viewer fs-filter{flex:0 0 auto;margin-top:10px;margin-bottom:0}.report .viewer ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.report .resizer:before{content:\"\";width:0px;background:#0000001f;transition:width .12s ease,background-color .12s ease}.report .resizer:hover:before{width:3px;background:var(--brand-primary-color)}.report.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.report.resizing .resizer:before{width:3px;background:var(--brand-primary-color)}.report.resizing .chat,.report.resizing .viewer{pointer-events:none}::ng-deep body.body-report-reports .mat-mdc-card-content{display:flex;flex-direction:column;box-sizing:border-box}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel{flex:1;min-height:0}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel router-outlet+ng-component{height:100%}.master-toggle.active{background:#eceff3}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$2.FsAutocompleteChipsComponent, selector: "fs-autocomplete-chips", inputs: ["fetch", "appearance", "floatLabel", "readonly", "size", "label", "placeholder", "chipImage", "chipBackground", "chipColor", "chipIcon", "chipIconColor", "chipClass", "chipPadding", "shape", "hint", "allowText", "allowObject", "delay", "minPanelWidth", "maxPanelHeight", "validateText", "removable", "allowClear", "color", "background", "orderable", "padless", "initOnClick", "fetchOnFocus", "multiple", "multipleAdd", "confirm", "disabled", "groupBy", "panelWidth", "panelClass", "compareWith"], outputs: ["selected", "removed", "reordered", "clear", "panelOpened", "panelClosed"] }, { kind: "directive", type: i2$2.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "directive", type: i2$2.FsAutocompleteChipsStaticDirective, selector: "[fsAutocompleteChipsStatic]", inputs: ["show", "disable"], outputs: ["click", "selected"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i1$2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10767
10908
  }
10768
10909
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponent, decorators: [{
@@ -10777,7 +10918,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
10777
10918
  ReportExportService,
10778
10919
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
10779
10920
  // this route's chunk.
10780
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-qCBE3LqM.mjs').then((module) => module.default) }),
10921
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CFnbJx44.mjs').then((module) => module.default) }),
10781
10922
  ], imports: [
10782
10923
  FormsModule,
10783
10924
  FsAutocompleteChipsModule,
@@ -12464,7 +12605,7 @@ class FsAiReportComponent {
12464
12605
  ReportAiChatData,
12465
12606
  { provide: FsAiChatData, useExisting: ReportAiChatData },
12466
12607
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
12467
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-qCBE3LqM.mjs').then((module) => module.default) }),
12608
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CFnbJx44.mjs').then((module) => module.default) }),
12468
12609
  ], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div class=\"fs-ai-report fs-column\">\n\n <div class=\"fs-ai-report__header fs-row.align-center\">\n <h2 class=\"fs-ai-report__title fs-flex\">{{ report()?.name }}</h2>\n\n @if (report()) {\n <!-- Report-level filters, inline with the toolbar actions. fs-filter\n reads its config once at init, so it's keyed on the filter\n signature: when the report-level filter set changes the block is\n recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters()) {\n @for (key of [reportFilterKey()]; track key) {\n <fs-filter\n class=\"fs-ai-report__filters\"\n [config]=\"reportFilterConfig()\">\n </fs-filter>\n }\n }\n\n <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode()) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- The zone every date in this report is filtered and grouped in, the\n same for every viewer. Shown only when the server sends it; a click\n opens Report settings, where it is changed. -->\n @if (report()?.effectiveTimezone; as effectiveTimezone) {\n <button\n mat-button\n type=\"button\"\n class=\"fs-ai-report__timezone\"\n [matTooltip]=\"'Dates are filtered and grouped in ' + effectiveTimezone + ' for everyone. Change it in Report settings.'\"\n (click)=\"reportSettings()\">\n <mat-icon>schedule</mat-icon>\n {{ timezoneLabel() }}\n </button>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode()\"\n [matTooltip]=\"editMode() ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</mat-icon>\n </button>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Export\"\n [fsMenuTriggerFor]=\"exportMenu\">\n <mat-icon>download</mat-icon>\n </button>\n <fs-menu #exportMenu>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n Export Raw Data\n </ng-template>\n @if ((report()?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Report settings\"\n (click)=\"reportSettings()\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n\n <div\n #split\n class=\"fs-ai-report__split fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n [basePath]=\"basePath()\"\n [requestData]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n\n <div class=\"viewer fs-flex fs-column\">\n @if (report()) {\n <app-report-canvas\n [report]=\"report()\"\n [editMode]=\"editMode()\"\n [pageId]=\"pageId()\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\n </app-report-canvas>\n }\n </div>\n </div>\n</div>\n", styles: [".fs-ai-report{height:100%;min-height:500px;display:flex;flex-direction:column}.fs-ai-report__header{flex:0 0 auto;margin-bottom:10px}.fs-ai-report__title{margin:0;min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fs-ai-report__filters{flex:0 1 auto;min-width:0;display:block;margin:0 5px 0 0}.fs-ai-report__filters ::ng-deep .filter-bar-container{align-items:center!important}.fs-ai-report__filters ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.fs-ai-report__timezone{--mat-button-text-label-text-color: var(--fs-color-text-muted, light-dark(rgba(0, 0, 0, .54), rgba(255, 255, 255, .6)));color:var(--fs-color-text-muted, light-dark(rgba(0, 0, 0, .54), rgba(255, 255, 255, .6)));white-space:nowrap;flex:0 0 auto}.fs-ai-report__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.fs-ai-report__split{flex:1 1 auto;min-height:0}.fs-ai-report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:400px;min-width:0;border:none}.fs-ai-report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.fs-ai-report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.fs-ai-report .resizer:before{content:\"\";width:3px;border-radius:3px;background:transparent;transition:background-color .12s ease}.fs-ai-report .resizer:hover:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.fs-ai-report__split.resizing .resizer:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing .chat,.fs-ai-report__split.resizing .viewer{pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i1$2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$3.MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i3$3.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12469
12610
  }
12470
12611
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, decorators: [{
@@ -12482,7 +12623,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
12482
12623
  ReportAiChatData,
12483
12624
  { provide: FsAiChatData, useExisting: ReportAiChatData },
12484
12625
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
12485
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-qCBE3LqM.mjs').then((module) => module.default) }),
12626
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CFnbJx44.mjs').then((module) => module.default) }),
12486
12627
  ], imports: [
12487
12628
  FsFilterModule,
12488
12629
  FsMenuModule,
@@ -12516,4 +12657,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
12516
12657
  */
12517
12658
 
12518
12659
  export { FsAiReportsComponent as F, GRANULARITY_OPTIONS as G, REPORT_CHART_COLORS_CSS as R, REPORT_CHART_FONT_FAMILY as a, REPORT_AXIS_LABEL_FONT_SIZE as b, ReportComponent as c, FsAiReportComponent as d, ReportData as e, ReportService as f, ReportFilterStateService as g, ReportNodeType as h, ReportState as i, ReportValueFormat as j, ReportListColumnFormat as k, ReportNumberUnits as l, ReportChartDataLabels as m, ReportChartColor as n, ReportChartLineStyle as o, ReportChartMarkStat as p, ReportChartDateLabel as q, ReportChangePolarity as r };
12519
- //# sourceMappingURL=firestitch-report-firestitch-report-DbsWbE-j.mjs.map
12660
+ //# sourceMappingURL=firestitch-report-firestitch-report-CSylkyko.mjs.map