@nuxtseo/design-system 0.0.0-stage → 0.1.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.
- package/README.md +101 -2
- package/app/app.config.ts +596 -0
- package/app/component-attributes.d.ts +8 -0
- package/app/components/README.md +38 -0
- package/app/components/brand/UiBrandMark.vue +63 -0
- package/app/components/brand/UiModuleIcon.vue +45 -0
- package/app/components/brand/UiProLettering.vue +26 -0
- package/app/components/brand/UiRangeTerrain.vue +311 -0
- package/app/components/brand/range-glyphs.ts +18 -0
- package/app/components/container/BrowserTab.vue +145 -0
- package/app/components/container/IntegrationRow.vue +120 -0
- package/app/components/container/KnowDoRails.vue +40 -0
- package/app/components/container/RailPanel.vue +93 -0
- package/app/components/container/UiAlert.vue +120 -0
- package/app/components/container/UiAppShell.vue +165 -0
- package/app/components/container/UiCard.vue +204 -0
- package/app/components/container/UiDisclosure.vue +43 -0
- package/app/components/container/UiEmptyState.vue +102 -0
- package/app/components/container/UiErrorState.vue +61 -0
- package/app/components/container/UiLoadingState.vue +20 -0
- package/app/components/container/UiPageHeader.vue +95 -0
- package/app/components/container/UiPopover.vue +102 -0
- package/app/components/container/UiSampleDataOverlay.vue +47 -0
- package/app/components/container/UiSectionHeader.vue +83 -0
- package/app/components/container/UiSummaryStrip.vue +40 -0
- package/app/components/container/UiToast.vue +230 -0
- package/app/components/container/UiToaster.vue +271 -0
- package/app/components/container/UiWidgetState.vue +143 -0
- package/app/components/container/ui-nav-drawer.ts +48 -0
- package/app/components/data/ActionRail.vue +152 -0
- package/app/components/data/FacetStrip.vue +93 -0
- package/app/components/data/UiBarCell.vue +37 -0
- package/app/components/data/UiBarChart.vue +281 -0
- package/app/components/data/UiChartAnnotationControls.vue +74 -0
- package/app/components/data/UiChartAnnotations.vue +163 -0
- package/app/components/data/UiChartBrushOverlay.vue +96 -0
- package/app/components/data/UiChartDataReveal.vue +81 -0
- package/app/components/data/UiChartFrame.vue +123 -0
- package/app/components/data/UiChartTooltip.vue +81 -0
- package/app/components/data/UiCoverageNote.vue +90 -0
- package/app/components/data/UiCwvSparkline.vue +188 -0
- package/app/components/data/UiDataList.vue +276 -0
- package/app/components/data/UiDataTableSection.vue +232 -0
- package/app/components/data/UiDistributionBars.vue +164 -0
- package/app/components/data/UiDonutChart.vue +132 -0
- package/app/components/data/UiDoorwayColumn.vue +108 -0
- package/app/components/data/UiDoorwayGrid.vue +58 -0
- package/app/components/data/UiDoorwayRow.vue +83 -0
- package/app/components/data/UiEntitySummary.vue +261 -0
- package/app/components/data/UiFactsGrid.vue +107 -0
- package/app/components/data/UiImpactMeter.vue +33 -0
- package/app/components/data/UiIssueRow.vue +149 -0
- package/app/components/data/UiLineChart.vue +431 -0
- package/app/components/data/UiMetricBarPopover.vue +121 -0
- package/app/components/data/UiMetricLabel.vue +30 -0
- package/app/components/data/UiMetricStat.vue +73 -0
- package/app/components/data/UiMetricToggle.vue +156 -0
- package/app/components/data/UiMetricsRow.vue +64 -0
- package/app/components/data/UiPositionMetric.vue +53 -0
- package/app/components/data/UiRelatedFixes.vue +113 -0
- package/app/components/data/UiRichText.vue +325 -0
- package/app/components/data/UiScatterPlot.vue +144 -0
- package/app/components/data/UiSeverityMarker.vue +30 -0
- package/app/components/data/UiSparkColumns.vue +160 -0
- package/app/components/data/UiSparkline.vue +681 -0
- package/app/components/data/UiStat.vue +445 -0
- package/app/components/data/UiStats.vue +76 -0
- package/app/components/data/UiTable.vue +517 -0
- package/app/components/data/UiTableExport.vue +40 -0
- package/app/components/data/UiTableFrame.vue +149 -0
- package/app/components/data/UiTablePagination.vue +37 -0
- package/app/components/data/UiTableShell.vue +48 -0
- package/app/components/data/UiTableTd.vue +39 -0
- package/app/components/data/UiTableTh.vue +61 -0
- package/app/components/data/UiTopEntityStackChart.vue +393 -0
- package/app/components/data/cells/UiTableDash.vue +3 -0
- package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
- package/app/components/data/cells/UiTableMetricCell.vue +72 -0
- package/app/components/data/cells/UiTablePathCell.vue +76 -0
- package/app/components/data/cells/UiTableScoreTile.vue +49 -0
- package/app/components/data/cells/UiTableTrendCell.vue +41 -0
- package/app/components/element/UiAtmosphere.vue +64 -0
- package/app/components/element/UiAuthHeading.vue +24 -0
- package/app/components/element/UiAuthProviders.vue +46 -0
- package/app/components/element/UiBrandIcon.vue +39 -0
- package/app/components/element/UiButton.vue +549 -0
- package/app/components/element/UiCelebration.vue +65 -0
- package/app/components/element/UiChannelChips.vue +62 -0
- package/app/components/element/UiChatQuickLink.vue +319 -0
- package/app/components/element/UiChip.vue +101 -0
- package/app/components/element/UiConfirmDialog.vue +84 -0
- package/app/components/element/UiCopyButton.vue +90 -0
- package/app/components/element/UiDitherAvatar.vue +177 -0
- package/app/components/element/UiDotLabel.vue +19 -0
- package/app/components/element/UiEjectMenu.vue +181 -0
- package/app/components/element/UiEjectOverlay.vue +29 -0
- package/app/components/element/UiFavicon.vue +235 -0
- package/app/components/element/UiFaviconStack.vue +84 -0
- package/app/components/element/UiFilterMenu.vue +324 -0
- package/app/components/element/UiFocusedSurface.vue +129 -0
- package/app/components/element/UiHealthDot.vue +32 -0
- package/app/components/element/UiHelpLabel.vue +28 -0
- package/app/components/element/UiIcon.vue +22 -0
- package/app/components/element/UiInput.vue +35 -0
- package/app/components/element/UiLogoPlausible.vue +12 -0
- package/app/components/element/UiLogoPro.vue +25 -0
- package/app/components/element/UiLogoUmami.vue +12 -0
- package/app/components/element/UiNavIcon.vue +107 -0
- package/app/components/element/UiNavList.vue +156 -0
- package/app/components/element/UiOnboardingAnswers.vue +125 -0
- package/app/components/element/UiOverflowCount.vue +31 -0
- package/app/components/element/UiProBadge.vue +19 -0
- package/app/components/element/UiProgressCircle.vue +79 -0
- package/app/components/element/UiProgressPercent.vue +35 -0
- package/app/components/element/UiRelativeTime.vue +58 -0
- package/app/components/element/UiScrollFade.vue +16 -0
- package/app/components/element/UiSelect.vue +55 -0
- package/app/components/element/UiSeverityDot.vue +27 -0
- package/app/components/element/UiSiteFavicon.vue +24 -0
- package/app/components/element/UiSkeleton.vue +148 -0
- package/app/components/element/UiSlotText.vue +65 -0
- package/app/components/element/UiSourceLogos.vue +113 -0
- package/app/components/element/UiSourceRail.vue +71 -0
- package/app/components/element/UiStaticTerminal.vue +107 -0
- package/app/components/element/UiStatusBadge.vue +62 -0
- package/app/components/element/UiSyncDot.vue +46 -0
- package/app/components/element/UiTabs.vue +245 -0
- package/app/components/element/UiTogglePill.vue +86 -0
- package/app/components/element/UiTooltip.vue +390 -0
- package/app/components/element/UiTooltipGrid.vue +457 -0
- package/app/components/element/UiTrend.vue +184 -0
- package/app/components/element/UiTrialBenefits.vue +71 -0
- package/app/components/element/UiUrlLabel.vue +75 -0
- package/app/components/element/UiValenceDot.vue +37 -0
- package/app/components/element/UiWizardModal.vue +284 -0
- package/app/components/element/UiWizardNav.vue +59 -0
- package/app/components/element/UiWizardProgress.vue +78 -0
- package/app/components/element/UiWizardShell.vue +43 -0
- package/app/components/element/eject-menu.ts +359 -0
- package/app/components/element/eject-tracker.ts +34 -0
- package/app/components/element/onboarding-answers.ts +68 -0
- package/app/components/element/onboarding-intents.ts +19 -0
- package/app/composables/dataVizColors.ts +350 -0
- package/app/composables/formatting.ts +252 -0
- package/app/composables/semanticColors.ts +80 -0
- package/app/composables/useActivityOverlay.ts +55 -0
- package/app/composables/useAppShellNav.ts +21 -0
- package/app/composables/useChartBrush.ts +221 -0
- package/app/composables/useChartHover.ts +153 -0
- package/app/composables/useChartTickPlan.ts +186 -0
- package/app/composables/useCloseOnNavigation.ts +7 -0
- package/app/composables/useConfirm.ts +38 -0
- package/app/composables/useEdgeFade.ts +37 -0
- package/app/composables/useEject.ts +69 -0
- package/app/composables/useEntranceMotion.ts +22 -0
- package/app/composables/useFaviconBacking.ts +164 -0
- package/app/composables/useGridCols.ts +39 -0
- package/app/composables/useIssueOverlay.ts +43 -0
- package/app/composables/useOnboardingService.ts +106 -0
- package/app/composables/useOpenedOnce.ts +19 -0
- package/app/composables/useScrollSpy.ts +178 -0
- package/app/composables/useSquircleFallback.ts +21 -0
- package/app/composables/useWizardRoute.ts +123 -0
- package/app/plugins/favicon.ts +29 -0
- package/app/plugins/lazy-motion.ts +22 -0
- package/app/plugins/selection-highlight.client.ts +176 -0
- package/app/shared/cli.ts +10 -0
- package/app/shared/error-state.ts +150 -0
- package/app/shared/icons.ts +1 -0
- package/app/shared/motion.ts +1 -0
- package/app/shared/nav.ts +38 -0
- package/app/shared/navigation.ts +5 -0
- package/app/shared/static-terminal.ts +14 -0
- package/app/shared/table-export.ts +36 -0
- package/app/shared/table.ts +105 -0
- package/app/shared/ui-icons.ts +1 -0
- package/app/shared/view-state.ts +85 -0
- package/app/shared/widget-failure.ts +126 -0
- package/app/utils/chartAnnotations.ts +326 -0
- package/app/utils/countries.ts +337 -0
- package/app/utils/export-download.ts +100 -0
- package/app/utils/favicon-fallback.ts +12 -0
- package/app/utils/icon-color.ts +105 -0
- package/app/utils/number.ts +74 -0
- package/app/utils/render-link.ts +14 -0
- package/app/utils/slot-text.ts +18 -0
- package/app/utils/squircle.ts +154 -0
- package/app/utils/topEntityStack.ts +197 -0
- package/app/utils/vue.ts +57 -0
- package/build/design-system.ts +47 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1892 -0
- package/nuxt.config.ts +132 -0
- package/package.json +86 -3
- package/public/brand-icon-dark.png +0 -0
- package/public/brand-icon-light.png +0 -0
- package/public/favicon-dark.png +0 -0
- package/public/favicon-light.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/fonts/README.md +31 -0
- package/public/fonts/fira-code-OFL.txt +93 -0
- package/public/fonts/fira-code-var-latin.woff2 +0 -0
- package/public/fonts/hubot-sans-OFL.txt +93 -0
- package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
- package/public/icon-dark.png +0 -0
- package/public/icon.png +0 -0
- package/public/touch-icon-dark.png +0 -0
- package/public/touch-icon.png +0 -0
- package/server/api/favicon.get.ts +122 -0
- package/server/utils/favicon-source.ts +51 -0
- package/shared/contracts/feedback.ts +42 -0
- package/shared/contracts/index.ts +1 -0
- package/shared/delivery-channels.ts +53 -0
- package/shared/icons/index.ts +143 -0
- package/shared/icons/registry.ts +347 -0
- package/shared/icons/ui-slots.ts +58 -0
- package/shared/markdown.ts +44 -0
- package/shared/motion.ts +120 -0
- package/shared/rangi.ts +60 -0
- package/shared/rich-text.ts +34 -0
- package/shared/ui-icons.ts +6 -0
- package/shared/utils/urls.ts +64 -0
- package/types/index.ts +1 -0
- package/types/ui.ts +339 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV and JSON exports for data lists and tables (ADR-0076).
|
|
3
|
+
* VISION principle 7 (independence): every data list ships a way out. One
|
|
4
|
+
* serialiser for every surface, so no page re-inlines its own `toCsv`.
|
|
5
|
+
*
|
|
6
|
+
* The serialisers are pure. Only `downloadCsv` and `downloadJson` touch the DOM.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One exported column: the header a spreadsheet shows, and the cell value. */
|
|
10
|
+
export interface ExportColumn<T> {
|
|
11
|
+
/** The JSON key. Falls back to `header`. */
|
|
12
|
+
readonly key?: string
|
|
13
|
+
readonly header: string
|
|
14
|
+
readonly value: (row: T) => unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A spreadsheet runs a cell that starts with one of these as a formula, so a
|
|
19
|
+
* row value such as `=HYPERLINK(...)` from a crawled page would execute on open.
|
|
20
|
+
* Tab and carriage return start a formula in some spreadsheets too (OWASP CSV
|
|
21
|
+
* injection).
|
|
22
|
+
*/
|
|
23
|
+
const FORMULA_PREFIX = /^[=+\-@\t\r]/
|
|
24
|
+
|
|
25
|
+
function cellText(value: unknown): string {
|
|
26
|
+
if (value === null || value === undefined)
|
|
27
|
+
return ''
|
|
28
|
+
if (value instanceof Date)
|
|
29
|
+
return Number.isNaN(value.getTime()) ? '' : value.toISOString()
|
|
30
|
+
if (typeof value === 'object')
|
|
31
|
+
return JSON.stringify(value)
|
|
32
|
+
return String(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One quoted CSV cell. A text cell that starts with a formula character gets a
|
|
37
|
+
* leading apostrophe, so the spreadsheet shows it as text. A finite number
|
|
38
|
+
* stays a number, so `-12` still sorts and sums as a number.
|
|
39
|
+
*/
|
|
40
|
+
export function csvCell(value: unknown): string {
|
|
41
|
+
const text = cellText(value)
|
|
42
|
+
const isNumber = typeof value === 'number' && Number.isFinite(value)
|
|
43
|
+
const safe = !isNumber && FORMULA_PREFIX.test(text) ? `'${text}` : text
|
|
44
|
+
return `"${safe.replace(/"/g, '""')}"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Serialise rows to CSV over the given ordered columns. The first line holds the
|
|
49
|
+
* headers. Lines end with CRLF (RFC 4180). Pure.
|
|
50
|
+
*/
|
|
51
|
+
export function tableToCsv<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): string {
|
|
52
|
+
const lines = [
|
|
53
|
+
columns.map(column => csvCell(column.header)).join(','),
|
|
54
|
+
...rows.map(row => columns.map(column => csvCell(column.value(row))).join(',')),
|
|
55
|
+
]
|
|
56
|
+
return `${lines.join('\r\n')}\r\n`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Serialise rows to records keyed by each column's key, else its header. Pure. */
|
|
60
|
+
export function tableToRecords<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): Record<string, unknown>[] {
|
|
61
|
+
return rows.map(row => Object.fromEntries(columns.map(column => [column.key ?? column.header, column.value(row)])))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Columns from row keys, for rows whose keys are already their headers. */
|
|
65
|
+
export function keyColumns<T>(keys: readonly (keyof T & string)[]): ExportColumn<T>[] {
|
|
66
|
+
return keys.map(key => ({ header: key, value: row => row[key] }))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Serialise rows to CSV over the given ordered keys. Pure. */
|
|
70
|
+
export function rowsToCsv<T>(rows: readonly T[], keys: readonly (keyof T & string)[]): string {
|
|
71
|
+
return tableToCsv(rows, keyColumns(keys))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function triggerDownload(filename: string, content: string, mime: string): void {
|
|
75
|
+
const blob = new Blob([content], { type: mime })
|
|
76
|
+
const url = URL.createObjectURL(blob)
|
|
77
|
+
const a = document.createElement('a')
|
|
78
|
+
a.href = url
|
|
79
|
+
a.download = filename
|
|
80
|
+
a.click()
|
|
81
|
+
URL.revokeObjectURL(url)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Download CSV text. The byte order mark makes Excel read the file as UTF-8,
|
|
86
|
+
* so a non-ASCII query or URL keeps its characters.
|
|
87
|
+
*/
|
|
88
|
+
export function downloadCsvText(filename: string, csv: string): void {
|
|
89
|
+
triggerDownload(filename, `\uFEFF${csv}`, 'text/csv;charset=utf-8')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Serialise rows to CSV over the given ordered keys and download it. */
|
|
93
|
+
export function downloadCsv<T>(filename: string, rows: readonly T[], keys: readonly (keyof T & string)[]): void {
|
|
94
|
+
downloadCsvText(filename, rowsToCsv(rows, keys))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Serialise any value to pretty JSON and download it. */
|
|
98
|
+
export function downloadJson(filename: string, data: unknown): void {
|
|
99
|
+
triggerDownload(filename, JSON.stringify(data, null, 2), 'application/json')
|
|
100
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function faviconProxyPath(domain: string): string {
|
|
2
|
+
return `/api/favicon?domain=${encodeURIComponent(domain)}&fallback=blank`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function faviconFallbackInitial(fallbackLabel: string, domain: string): string {
|
|
6
|
+
const source = fallbackLabel.trim() || domain.replace(/^www\./, '')
|
|
7
|
+
return (source[0] ?? '?').toUpperCase()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isBlankFaviconSize(width: number, height: number): boolean {
|
|
11
|
+
return width === 1 && height === 1
|
|
12
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { tryUseNuxtApp, useAppConfig } from 'nuxt/app'
|
|
2
|
+
|
|
3
|
+
export type IconColor
|
|
4
|
+
= | 'neutral'
|
|
5
|
+
| 'primary'
|
|
6
|
+
| 'pro'
|
|
7
|
+
| 'success'
|
|
8
|
+
| 'warning'
|
|
9
|
+
| 'error'
|
|
10
|
+
| 'info'
|
|
11
|
+
| 'red'
|
|
12
|
+
| 'orange'
|
|
13
|
+
| 'amber'
|
|
14
|
+
| 'yellow'
|
|
15
|
+
| 'green'
|
|
16
|
+
| 'emerald'
|
|
17
|
+
| 'teal'
|
|
18
|
+
| 'blue'
|
|
19
|
+
| 'cyan'
|
|
20
|
+
| 'sky'
|
|
21
|
+
| 'indigo'
|
|
22
|
+
| 'violet'
|
|
23
|
+
| 'purple'
|
|
24
|
+
| 'pink'
|
|
25
|
+
| 'rose'
|
|
26
|
+
| 'gray'
|
|
27
|
+
| 'slate'
|
|
28
|
+
|
|
29
|
+
/** The container fill and glyph colour classes one icon colour role paints. */
|
|
30
|
+
export interface IconColorClasses {
|
|
31
|
+
bg: string
|
|
32
|
+
text: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A role name to its classes. A site adds or remaps roles in app.config. */
|
|
36
|
+
export type IconColorRoles = Readonly<Record<string, IconColorClasses>>
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The layer's icon colour roles. A site adds a role, or remaps one, under
|
|
40
|
+
* `designSystem.iconColors` in its app.config. Tailwind only emits classes it
|
|
41
|
+
* finds as literals in scanned source, so the site writes them there in full.
|
|
42
|
+
*/
|
|
43
|
+
export const ICON_COLOR_ROLES: Readonly<Record<IconColor, IconColorClasses>> = {
|
|
44
|
+
neutral: { bg: 'bg-accented', text: 'text-muted' },
|
|
45
|
+
primary: { bg: 'bg-primary/10', text: 'text-primary' },
|
|
46
|
+
pro: { bg: 'bg-pro/10', text: 'text-pro' },
|
|
47
|
+
success: { bg: 'bg-success/10', text: 'text-success' },
|
|
48
|
+
warning: { bg: 'bg-warning/10', text: 'text-warning' },
|
|
49
|
+
error: { bg: 'bg-error/10', text: 'text-error' },
|
|
50
|
+
info: { bg: 'bg-info/10', text: 'text-info' },
|
|
51
|
+
red: { bg: 'bg-red-500/10', text: 'text-red-500' },
|
|
52
|
+
orange: { bg: 'bg-orange-500/10', text: 'text-orange-500' },
|
|
53
|
+
amber: { bg: 'bg-amber-500/10', text: 'text-amber-500' },
|
|
54
|
+
yellow: { bg: 'bg-yellow-500/10', text: 'text-yellow-500' },
|
|
55
|
+
green: { bg: 'bg-green-500/10', text: 'text-green-500' },
|
|
56
|
+
emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-500' },
|
|
57
|
+
teal: { bg: 'bg-teal-500/10', text: 'text-teal-500' },
|
|
58
|
+
blue: { bg: 'bg-blue-500/10', text: 'text-blue-500' },
|
|
59
|
+
cyan: { bg: 'bg-cyan-500/10', text: 'text-cyan-500' },
|
|
60
|
+
sky: { bg: 'bg-sky-500/10', text: 'text-sky-500' },
|
|
61
|
+
indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-500' },
|
|
62
|
+
violet: { bg: 'bg-violet-500/10', text: 'text-violet-500' },
|
|
63
|
+
purple: { bg: 'bg-purple-500/10', text: 'text-purple-500' },
|
|
64
|
+
pink: { bg: 'bg-pink-500/10', text: 'text-pink-500' },
|
|
65
|
+
rose: { bg: 'bg-rose-500/10', text: 'text-rose-500' },
|
|
66
|
+
// 'gray' is a semantic alias for 'neutral': raw `gray-*` Tailwind utilities
|
|
67
|
+
// render Tailwind's stock cold gray, not the app's warm-slate neutral ramp
|
|
68
|
+
// (global.css re-derives `--color-slate-*`, not `--color-gray-*`), so this
|
|
69
|
+
// reuses the same semantic tokens as 'neutral' rather than the raw hue.
|
|
70
|
+
gray: { bg: 'bg-accented', text: 'text-muted' },
|
|
71
|
+
slate: { bg: 'bg-slate-500/10', text: 'text-slate-500' },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pure lookup: the classes for `color` in `roles`. An unknown or empty role
|
|
76
|
+
* paints `neutral`.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveIconColor(color: IconColor | string | null | undefined, roles: IconColorRoles = ICON_COLOR_ROLES): IconColorClasses {
|
|
79
|
+
const neutral = roles.neutral ?? ICON_COLOR_ROLES.neutral
|
|
80
|
+
if (!color)
|
|
81
|
+
return neutral
|
|
82
|
+
return roles[color] ?? neutral
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The app.config roles, or none outside a Nuxt app. */
|
|
86
|
+
function siteIconColorRoles(): IconColorRoles | undefined {
|
|
87
|
+
if (!tryUseNuxtApp())
|
|
88
|
+
return undefined
|
|
89
|
+
return useAppConfig().designSystem?.iconColors as IconColorRoles | undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The classes for an icon colour role, including roles the site adds in
|
|
94
|
+
* app.config. Call it from component setup or render, where the Nuxt app is
|
|
95
|
+
* available. Each call reads app.config live, site role first, then layer
|
|
96
|
+
* role. A merged copy cached per config object went stale when a role was
|
|
97
|
+
* added to that same object after the first lookup.
|
|
98
|
+
*/
|
|
99
|
+
export function getIconColor(color: IconColor | string | null | undefined): IconColorClasses {
|
|
100
|
+
const site = siteIconColorRoles()
|
|
101
|
+
const neutral = site?.neutral ?? ICON_COLOR_ROLES.neutral
|
|
102
|
+
if (!color)
|
|
103
|
+
return neutral
|
|
104
|
+
return site?.[color] ?? (ICON_COLOR_ROLES as IconColorRoles)[color] ?? neutral
|
|
105
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculate percentage change between two values.
|
|
3
|
+
* Returns 0 if prev is 0 (avoids division by zero).
|
|
4
|
+
*
|
|
5
|
+
* Not clamped: a clamp here read 2 → 60 clicks as "+999%", and it hid the
|
|
6
|
+
* change from UiTrend, whose own clamp shows "10x+" above 999 with the exact
|
|
7
|
+
* value in its title. Clamping is presentation, so it stays in UiTrend.
|
|
8
|
+
*/
|
|
9
|
+
export function percentChange(current: number, prev: number): number {
|
|
10
|
+
if (!prev)
|
|
11
|
+
return 0
|
|
12
|
+
// Divide by the size of the base, so a rise from -10 to -5 reads as +50%,
|
|
13
|
+
// not -50%. Signed series (the desktop-minus-mobile gap) have negative bases.
|
|
14
|
+
return Math.round(((current - prev) / Math.abs(prev)) * 100) || 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Clamp a number between min and max values:
|
|
19
|
+
*
|
|
20
|
+
* @example clamp(-5, 1, 5) // 1
|
|
21
|
+
* @example clamp(10, 1, 5) // 5
|
|
22
|
+
*
|
|
23
|
+
* Or clamp an index to valid array indices:
|
|
24
|
+
*
|
|
25
|
+
* @example clamp(-5, [1, 2, 3, 4, 5]) // 0
|
|
26
|
+
* @example clamp(10, [1, 2, 3, 4, 5]) // 4
|
|
27
|
+
*/
|
|
28
|
+
export function clamp(value: number, arr: readonly unknown[]): number
|
|
29
|
+
export function clamp(value: number, min: number, max: number): number
|
|
30
|
+
export function clamp(value: number, a: number | readonly unknown[], b?: number): number {
|
|
31
|
+
if (isReadonlyArray(a))
|
|
32
|
+
return Math.min(Math.max(value, 0), a.length - 1)
|
|
33
|
+
const min = a
|
|
34
|
+
const max = b ?? min
|
|
35
|
+
return Math.min(Math.max(value, min), max)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isReadonlyArray(value: number | readonly unknown[]): value is readonly unknown[] {
|
|
39
|
+
return Array.isArray(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whole-number percentages of `values` that sum to exactly 100 (largest
|
|
44
|
+
* remainder). Rounding each share on its own made a column read 99% or 101%.
|
|
45
|
+
* All zero (or empty) gives all zero.
|
|
46
|
+
*/
|
|
47
|
+
export function roundedShares(values: readonly number[]): number[] {
|
|
48
|
+
const total = values.reduce((sum, v) => sum + Math.max(0, v), 0)
|
|
49
|
+
if (total <= 0)
|
|
50
|
+
return values.map(() => 0)
|
|
51
|
+
const exact = values.map(v => Math.max(0, v) / total * 100)
|
|
52
|
+
const floors = exact.map(Math.floor)
|
|
53
|
+
let left = 100 - floors.reduce((sum, v) => sum + v, 0)
|
|
54
|
+
const order = exact.map((v, i) => ({ i, frac: v - floors[i]! })).sort((a, b) => b.frac - a.frac || a.i - b.i)
|
|
55
|
+
for (const { i } of order) {
|
|
56
|
+
if (left <= 0)
|
|
57
|
+
break
|
|
58
|
+
floors[i]!++
|
|
59
|
+
left--
|
|
60
|
+
}
|
|
61
|
+
return floors
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A rounded share as a label. A non-zero slice never reads "0%", and a slice
|
|
66
|
+
* beside other non-zero slices never reads "100%".
|
|
67
|
+
*/
|
|
68
|
+
export function roundedShareLabel(rounded: number, value: number, othersNonZero: boolean): string {
|
|
69
|
+
if (value > 0 && rounded === 0)
|
|
70
|
+
return '<1%'
|
|
71
|
+
if (rounded === 100 && othersNonZero)
|
|
72
|
+
return '>99%'
|
|
73
|
+
return `${rounded}%`
|
|
74
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NuxtLinkProps } from 'nuxt/app'
|
|
2
|
+
import type { AnchorHTMLAttributes, VNode } from 'vue'
|
|
3
|
+
import type { NavigationDestination } from '../shared/navigation'
|
|
4
|
+
import { createVNode } from 'vue'
|
|
5
|
+
import { NuxtLink } from '#components'
|
|
6
|
+
|
|
7
|
+
type LinkProps = Omit<NuxtLinkProps, 'to' | 'href'>
|
|
8
|
+
& Omit<AnchorHTMLAttributes, keyof NuxtLinkProps>
|
|
9
|
+
& { to?: NavigationDestination, href?: NavigationDestination }
|
|
10
|
+
|
|
11
|
+
// Check the destination before Vue expands the component's complete prop map.
|
|
12
|
+
export function renderLink(props: LinkProps, children: (() => unknown) | Record<string, () => unknown>): VNode {
|
|
13
|
+
return createVNode(NuxtLink, props, typeof children === 'function' ? { default: children } : children)
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared options for the design-system slot text roll.
|
|
3
|
+
*
|
|
4
|
+
* Kept as a local type so components can expose tuning without depending on the
|
|
5
|
+
* external `slot-text` package or carrying its DOM controller runtime.
|
|
6
|
+
*/
|
|
7
|
+
export interface SlotTextOptions {
|
|
8
|
+
direction?: 'up' | 'down'
|
|
9
|
+
stagger?: number
|
|
10
|
+
duration?: number
|
|
11
|
+
exitOffset?: number
|
|
12
|
+
easing?: string
|
|
13
|
+
bounce?: number
|
|
14
|
+
color?: string | ((index: number, total: number) => string)
|
|
15
|
+
colorFade?: number
|
|
16
|
+
skipUnchanged?: boolean
|
|
17
|
+
interrupt?: boolean
|
|
18
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma-style squircle (smooth-corner) SVG path generation.
|
|
3
|
+
*
|
|
4
|
+
* Vendored and trimmed from Lisse (`@lisse/core`, MIT © Jace), which in turn
|
|
5
|
+
* implements Figma's smoothing algorithm via MartinRGB's approximation:
|
|
6
|
+
* https://www.figma.com/blog/desperately-seeking-squircles/
|
|
7
|
+
* https://github.com/MartinRGB/Figma_Squircles_Approximation
|
|
8
|
+
*
|
|
9
|
+
* We only need the uniform-radius squircle curve (not arc / superellipse /
|
|
10
|
+
* clothoid, nor per-corner config), so this is the squircle path math only.
|
|
11
|
+
* Used as the Safari/Firefox fallback for the native CSS `corner-shape:
|
|
12
|
+
* squircle` progressive enhancement (see global.css + UiCard).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface SquircleOptions {
|
|
16
|
+
/** Corner radius in px. */
|
|
17
|
+
radius: number
|
|
18
|
+
/** 0 (sharp) → 1 (max). Default 0.6 (Figma's value). */
|
|
19
|
+
smoothing?: number
|
|
20
|
+
/** Keep the smoothing even when the corner is space-constrained. Default true. */
|
|
21
|
+
preserveSmoothing?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CornerPathParams {
|
|
25
|
+
a: number
|
|
26
|
+
b: number
|
|
27
|
+
c: number
|
|
28
|
+
d: number
|
|
29
|
+
p: number
|
|
30
|
+
arcSectionLength: number
|
|
31
|
+
cornerRadius: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const toRadians = (deg: number) => (deg * Math.PI) / 180
|
|
35
|
+
const f = (n: number) => n.toFixed(4)
|
|
36
|
+
|
|
37
|
+
/** Tagged template that formats interpolated numbers to 4 decimal places. */
|
|
38
|
+
function rounded(strings: TemplateStringsArray, ...values: number[]): string {
|
|
39
|
+
let out = strings[0] ?? ''
|
|
40
|
+
for (let i = 0; i < values.length; i++) {
|
|
41
|
+
const value = values[i]
|
|
42
|
+
out += `${value === undefined ? '' : value.toFixed(4)}${strings[i + 1] ?? ''}`
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const adjacentsByCorner = {
|
|
48
|
+
topLeft: [{ corner: 'topRight', side: 'top' }, { corner: 'bottomLeft', side: 'left' }],
|
|
49
|
+
topRight: [{ corner: 'topLeft', side: 'top' }, { corner: 'bottomRight', side: 'right' }],
|
|
50
|
+
bottomLeft: [{ corner: 'bottomRight', side: 'bottom' }, { corner: 'topLeft', side: 'left' }],
|
|
51
|
+
bottomRight: [{ corner: 'bottomLeft', side: 'bottom' }, { corner: 'topRight', side: 'right' }],
|
|
52
|
+
} as const
|
|
53
|
+
|
|
54
|
+
type Corner = keyof typeof adjacentsByCorner
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Distribute available space among corners, clamping radii so they can't
|
|
58
|
+
* exceed the rectangle. Larger corners get priority. With a uniform radius
|
|
59
|
+
* this only matters on small/thin elements.
|
|
60
|
+
*/
|
|
61
|
+
function distributeAndNormalize(radius: number, width: number, height: number) {
|
|
62
|
+
const radii: Record<Corner, number> = { topLeft: radius, topRight: radius, bottomLeft: radius, bottomRight: radius }
|
|
63
|
+
const budgets: Record<Corner, number> = { topLeft: -1, topRight: -1, bottomLeft: -1, bottomRight: -1 }
|
|
64
|
+
|
|
65
|
+
;(Object.entries(radii) as [Corner, number][])
|
|
66
|
+
.sort(([, r1], [, r2]) => r2 - r1)
|
|
67
|
+
.forEach(([corner, r]) => {
|
|
68
|
+
const budget = Math.min(...adjacentsByCorner[corner].map((adj) => {
|
|
69
|
+
const adjRadius = radii[adj.corner]
|
|
70
|
+
if (r === 0 && adjRadius === 0)
|
|
71
|
+
return 0
|
|
72
|
+
const adjBudget = budgets[adj.corner]
|
|
73
|
+
const sideLength = adj.side === 'top' || adj.side === 'bottom' ? width : height
|
|
74
|
+
return adjBudget >= 0 ? sideLength - adjBudget : (r / (r + adjRadius)) * sideLength
|
|
75
|
+
}))
|
|
76
|
+
budgets[corner] = budget
|
|
77
|
+
radii[corner] = Math.min(r, budget)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return { radii, budgets }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Bézier-curve parameters for one corner (Figma squircle). */
|
|
84
|
+
function getPathParamsForCorner(cornerRadius: number, cornerSmoothing: number, preserveSmoothing: boolean, budget: number): CornerPathParams {
|
|
85
|
+
if (cornerRadius <= 0)
|
|
86
|
+
return { a: 0, b: 0, c: 0, d: 0, p: 0, arcSectionLength: 0, cornerRadius: 0 }
|
|
87
|
+
|
|
88
|
+
let p = (1 + cornerSmoothing) * cornerRadius
|
|
89
|
+
if (!preserveSmoothing) {
|
|
90
|
+
const maxSmoothing = budget / cornerRadius - 1
|
|
91
|
+
cornerSmoothing = Math.min(cornerSmoothing, maxSmoothing)
|
|
92
|
+
p = Math.min(p, budget)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const arcMeasure = 90 * (1 - cornerSmoothing)
|
|
96
|
+
const arcSectionLength = Math.sin(toRadians(arcMeasure / 2)) * cornerRadius * Math.sqrt(2)
|
|
97
|
+
const angleAlpha = (90 - arcMeasure) / 2
|
|
98
|
+
const p3ToP4Distance = cornerRadius * Math.tan(toRadians(angleAlpha / 2))
|
|
99
|
+
const angleBeta = 45 * cornerSmoothing
|
|
100
|
+
const c = p3ToP4Distance * Math.cos(toRadians(angleBeta))
|
|
101
|
+
const d = c * Math.tan(toRadians(angleBeta))
|
|
102
|
+
|
|
103
|
+
let b = (p - arcSectionLength - c - d) / 3
|
|
104
|
+
let a = 2 * b
|
|
105
|
+
if (preserveSmoothing && p > budget) {
|
|
106
|
+
const p1ToP3MaxDistance = budget - d - arcSectionLength - c
|
|
107
|
+
const minA = p1ToP3MaxDistance / 6
|
|
108
|
+
const maxB = p1ToP3MaxDistance - minA
|
|
109
|
+
b = Math.min(b, maxB)
|
|
110
|
+
a = p1ToP3MaxDistance - b
|
|
111
|
+
p = Math.min(p, budget)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { a, b, c, d, p, arcSectionLength, cornerRadius }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function drawTopRight({ cornerRadius, a, b, c, d, arcSectionLength }: CornerPathParams) {
|
|
118
|
+
return cornerRadius
|
|
119
|
+
? rounded`c ${a} 0 ${a + b} 0 ${a + b + c} ${d} a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${arcSectionLength} c ${d} ${c} ${d} ${b + c} ${d} ${a + b + c}`
|
|
120
|
+
: ''
|
|
121
|
+
}
|
|
122
|
+
function drawBottomRight({ cornerRadius, a, b, c, d, arcSectionLength }: CornerPathParams) {
|
|
123
|
+
return cornerRadius
|
|
124
|
+
? rounded`c 0 ${a} 0 ${a + b} ${-d} ${a + b + c} a ${cornerRadius} ${cornerRadius} 0 0 1 ${-arcSectionLength} ${arcSectionLength} c ${-c} ${d} ${-(b + c)} ${d} ${-(a + b + c)} ${d}`
|
|
125
|
+
: ''
|
|
126
|
+
}
|
|
127
|
+
function drawBottomLeft({ cornerRadius, a, b, c, d, arcSectionLength }: CornerPathParams) {
|
|
128
|
+
return cornerRadius
|
|
129
|
+
? rounded`c ${-a} 0 ${-(a + b)} 0 ${-(a + b + c)} ${-d} a ${cornerRadius} ${cornerRadius} 0 0 1 ${-arcSectionLength} ${-arcSectionLength} c ${-d} ${-c} ${-d} ${-(b + c)} ${-d} ${-(a + b + c)}`
|
|
130
|
+
: ''
|
|
131
|
+
}
|
|
132
|
+
function drawTopLeft({ cornerRadius, a, b, c, d, arcSectionLength }: CornerPathParams) {
|
|
133
|
+
return cornerRadius
|
|
134
|
+
? rounded`c 0 ${-a} 0 ${-(a + b)} ${d} ${-(a + b + c)} a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${-arcSectionLength} c ${c} ${-d} ${b + c} ${-d} ${a + b + c} ${-d}`
|
|
135
|
+
: ''
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** SVG path `d` string for a smooth-cornered rectangle, traversed clockwise from the top-left. */
|
|
139
|
+
export function squircleSvgPath(width: number, height: number, { radius, smoothing = 0.6, preserveSmoothing = true }: SquircleOptions): string {
|
|
140
|
+
if (width <= 0 || height <= 0)
|
|
141
|
+
return ''
|
|
142
|
+
const { radii, budgets } = distributeAndNormalize(radius, width, height)
|
|
143
|
+
const params = (c: Corner) => getPathParamsForCorner(radii[c], smoothing, preserveSmoothing, budgets[c])
|
|
144
|
+
const tl = params('topLeft')
|
|
145
|
+
const tr = params('topRight')
|
|
146
|
+
const br = params('bottomRight')
|
|
147
|
+
const bl = params('bottomLeft')
|
|
148
|
+
|
|
149
|
+
return `M ${f(tl.p)} 0`
|
|
150
|
+
+ ` L ${f(width - tr.p)} 0 ${drawTopRight(tr)}`
|
|
151
|
+
+ ` L ${f(width)} ${f(br.p)} L ${f(width)} ${f(height - br.p)} ${drawBottomRight(br)}`
|
|
152
|
+
+ ` L ${f(width - bl.p)} ${f(height)} L ${f(bl.p)} ${f(height)} ${drawBottomLeft(bl)}`
|
|
153
|
+
+ ` L 0 ${f(height - tl.p)} L 0 ${f(tl.p)} ${drawTopLeft(tl)} Z`
|
|
154
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Shared "top N + Other" stacked-trend bucketing — pure, dependency-free logic
|
|
2
|
+
// backing `UiTopEntityStackChart`. Used for the search-console Queries / Pages
|
|
3
|
+
// / Countries trend charts (manual-review-2026-08): pick the top N entities by
|
|
4
|
+
// total value over the range, zero-fill each against the full date axis (an
|
|
5
|
+
// entity absent on a given day reads as a zero band, never a gap), roll
|
|
6
|
+
// everything else into a single "Other" band, and coarsen the date axis into
|
|
7
|
+
// a bounded number of buckets so a wide range doesn't render one bar per day.
|
|
8
|
+
//
|
|
9
|
+
// "Other" has two modes:
|
|
10
|
+
// - `totals` supplied (the caller only fetched the top-K candidates, not the
|
|
11
|
+
// full entity universe): Other = total - sum(top N). This is the common
|
|
12
|
+
// case — GSC queries/pages have far more than 5 distinct values.
|
|
13
|
+
// - `totals` omitted (the caller supplied every candidate, e.g. a bounded
|
|
14
|
+
// dimension like country): Other = sum of every candidate beyond top N.
|
|
15
|
+
|
|
16
|
+
import type { ChartAnnotation } from './chartAnnotations'
|
|
17
|
+
import { toChartDayKey } from './chartAnnotations'
|
|
18
|
+
|
|
19
|
+
const EPSILON = 1e-9
|
|
20
|
+
|
|
21
|
+
export interface TopEntityDayRow {
|
|
22
|
+
/** ISO `YYYY-MM-DD` day. */
|
|
23
|
+
date: string
|
|
24
|
+
/** Stable entity identity (query text, page path, country code). */
|
|
25
|
+
key: string
|
|
26
|
+
/** Display label. Defaults to `key` when omitted at the call site. */
|
|
27
|
+
label: string
|
|
28
|
+
/** Metric value for this entity on this day (already the selected metric). */
|
|
29
|
+
value: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TopEntityStackBucket {
|
|
33
|
+
/** First day folded into this bucket. */
|
|
34
|
+
start: string
|
|
35
|
+
/** Last day folded into this bucket. */
|
|
36
|
+
end: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TopEntityStackSeries {
|
|
40
|
+
key: string
|
|
41
|
+
label: string
|
|
42
|
+
/** One value per bucket, aligned to `result.buckets`. */
|
|
43
|
+
values: number[]
|
|
44
|
+
isOther: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TopEntityStackResult {
|
|
48
|
+
buckets: TopEntityStackBucket[]
|
|
49
|
+
series: TopEntityStackSeries[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BucketTopEntitiesOptions {
|
|
53
|
+
/** Full canonical date axis, ascending, no gaps — the zero-fill target. */
|
|
54
|
+
dates: string[]
|
|
55
|
+
/** Sparse per-entity-per-day rows. A missing (entity, date) pair is 0. */
|
|
56
|
+
rows: TopEntityDayRow[]
|
|
57
|
+
/** How many entities stay explicit. Default 5. */
|
|
58
|
+
topN?: number
|
|
59
|
+
/** Site/aggregate total per date, aligned to `dates`. Drives Other = total - topN. */
|
|
60
|
+
totals?: number[]
|
|
61
|
+
/** Cap on rendered date buckets (adjacent days are summed together). Default 10. */
|
|
62
|
+
maxBuckets?: number
|
|
63
|
+
otherLabel?: string
|
|
64
|
+
/** Drop a candidate entirely before ranking/rolling it into Other — e.g. `isSearchOperatorQuery`. */
|
|
65
|
+
excludeKey?: (key: string) => boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function bucketTopEntities(opts: BucketTopEntitiesOptions): TopEntityStackResult {
|
|
69
|
+
const { dates, rows, topN = 5, totals, maxBuckets = 10, otherLabel = 'All Others', excludeKey } = opts
|
|
70
|
+
if (!dates.length)
|
|
71
|
+
return { buckets: [], series: [] }
|
|
72
|
+
|
|
73
|
+
const dateIndex = new Map(dates.map((d, i) => [d, i]))
|
|
74
|
+
const filteredRows = excludeKey ? rows.filter(r => !excludeKey(r.key)) : rows
|
|
75
|
+
|
|
76
|
+
const byKey = new Map<string, { label: string, values: number[] }>()
|
|
77
|
+
for (const row of filteredRows) {
|
|
78
|
+
const idx = dateIndex.get(row.date)
|
|
79
|
+
if (idx == null)
|
|
80
|
+
continue
|
|
81
|
+
let entry = byKey.get(row.key)
|
|
82
|
+
if (!entry) {
|
|
83
|
+
entry = { label: row.label || row.key, values: Array.from({ length: dates.length }).fill(0) as number[] }
|
|
84
|
+
byKey.set(row.key, entry)
|
|
85
|
+
}
|
|
86
|
+
entry.values[idx] = (entry.values[idx] ?? 0) + row.value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ranked = Array.from(byKey.entries(), ([key, entry]) => ({
|
|
90
|
+
key,
|
|
91
|
+
label: entry.label,
|
|
92
|
+
values: entry.values,
|
|
93
|
+
total: entry.values.reduce((sum, v) => sum + v, 0),
|
|
94
|
+
}))
|
|
95
|
+
.sort((a, b) => b.total - a.total || a.key.localeCompare(b.key))
|
|
96
|
+
|
|
97
|
+
const top = ranked.slice(0, topN)
|
|
98
|
+
const rest = ranked.slice(topN)
|
|
99
|
+
|
|
100
|
+
const otherValues = dates.map((_, i) => {
|
|
101
|
+
if (totals) {
|
|
102
|
+
const topSum = top.reduce((sum, s) => sum + (s.values[i] ?? 0), 0)
|
|
103
|
+
return Math.max(0, (totals[i] ?? 0) - topSum)
|
|
104
|
+
}
|
|
105
|
+
return rest.reduce((sum, s) => sum + (s.values[i] ?? 0), 0)
|
|
106
|
+
})
|
|
107
|
+
const hasOther = otherValues.some(v => v > EPSILON)
|
|
108
|
+
|
|
109
|
+
const daily: TopEntityStackSeries[] = top.map(s => ({
|
|
110
|
+
key: s.key,
|
|
111
|
+
label: s.label,
|
|
112
|
+
values: s.values,
|
|
113
|
+
isOther: false,
|
|
114
|
+
}))
|
|
115
|
+
if (hasOther) {
|
|
116
|
+
daily.push({
|
|
117
|
+
key: '__other__',
|
|
118
|
+
label: otherLabel,
|
|
119
|
+
values: otherValues,
|
|
120
|
+
isOther: true,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Coarsen the date axis: fold contiguous day-groups into one bucket per
|
|
125
|
+
// series (summed), bounded to `maxBuckets`. Buckets align on the newest day,
|
|
126
|
+
// so the OLDEST bucket takes the remainder (as UiSparkColumns does). Aligned
|
|
127
|
+
// on the oldest day, the newest bar held the leftover 1 to 2 days and, in
|
|
128
|
+
// `scale="absolute"`, read as a collapse in the latest period.
|
|
129
|
+
const bucketCount = Math.max(1, Math.min(maxBuckets, dates.length))
|
|
130
|
+
const groupSize = Math.ceil(dates.length / bucketCount)
|
|
131
|
+
const buckets: TopEntityStackBucket[] = []
|
|
132
|
+
const bucketedSeries: TopEntityStackSeries[] = daily.map(s => ({ ...s, values: [] }))
|
|
133
|
+
const remainder = dates.length % groupSize
|
|
134
|
+
|
|
135
|
+
for (let start = 0; start < dates.length; start = start === 0 && remainder ? remainder : start + groupSize) {
|
|
136
|
+
const end = start === 0 && remainder ? remainder : Math.min(start + groupSize, dates.length)
|
|
137
|
+
buckets.push({ start: dates[start]!, end: dates[end - 1]! })
|
|
138
|
+
daily.forEach((s, si) => {
|
|
139
|
+
let sum = 0
|
|
140
|
+
for (let i = start; i < end; i++) sum += s.values[i] ?? 0
|
|
141
|
+
bucketedSeries[si]!.values.push(sum)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { buckets, series: bucketedSeries }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface TopEntityStackRow {
|
|
149
|
+
i: number
|
|
150
|
+
values: number[]
|
|
151
|
+
shares: number[]
|
|
152
|
+
total: number
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Preserve counts for details and express each plotted bucket as a percentage. */
|
|
156
|
+
export function topEntityStackRows({ buckets, series }: TopEntityStackResult): TopEntityStackRow[] {
|
|
157
|
+
return buckets.map((_, i) => {
|
|
158
|
+
const values = series.map(s => s.values[i] ?? 0)
|
|
159
|
+
const total = values.reduce((sum, value) => sum + value, 0)
|
|
160
|
+
return { i, values, shares: values.map(value => total > 0 ? value / total * 100 : 0), total }
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Anchor day annotations onto the bucket index axis. An annotation lands on
|
|
166
|
+
* the bucket whose `start <= day <= end`; one outside every bucket, or with
|
|
167
|
+
* no day, drops out. Matching only a bucket's START day silently dropped
|
|
168
|
+
* about 8 of every 9 annotations on a 90-day range.
|
|
169
|
+
*/
|
|
170
|
+
export function anchorAnnotationsToBuckets(
|
|
171
|
+
annotations: readonly ChartAnnotation[] | undefined,
|
|
172
|
+
buckets: readonly TopEntityStackBucket[],
|
|
173
|
+
): ChartAnnotation[] {
|
|
174
|
+
if (!annotations?.length || !buckets.length)
|
|
175
|
+
return []
|
|
176
|
+
return annotations.flatMap((annotation) => {
|
|
177
|
+
const day = toChartDayKey(annotation.x)
|
|
178
|
+
if (!day)
|
|
179
|
+
return []
|
|
180
|
+
const i = buckets.findIndex(bucket => bucket.start <= day && day <= bucket.end)
|
|
181
|
+
return i === -1 ? [] : [{ ...annotation, x: i, dayKey: day }]
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Project the full reporting-day axis onto the chart's bucket domain. */
|
|
186
|
+
export function topEntityStackTimeline(buckets: TopEntityStackBucket[]): { dates: string[], positions: number[] } {
|
|
187
|
+
const dates: string[] = []
|
|
188
|
+
for (const bucket of buckets) {
|
|
189
|
+
const start = Date.parse(`${bucket.start}T00:00:00Z`)
|
|
190
|
+
const end = Date.parse(`${bucket.end}T00:00:00Z`)
|
|
191
|
+
for (let day = start; day <= end; day += 86_400_000)
|
|
192
|
+
dates.push(new Date(day).toISOString().slice(0, 10))
|
|
193
|
+
}
|
|
194
|
+
// Match the Overview's first-to-last-day axis, including a shorter first bucket.
|
|
195
|
+
const positions = dates.map((_, i) => dates.length === 1 ? 0 : i / (dates.length - 1) * buckets.length - 0.5)
|
|
196
|
+
return { dates, positions }
|
|
197
|
+
}
|