@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.
Files changed (225) hide show
  1. package/README.md +101 -2
  2. package/app/app.config.ts +596 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +232 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +125 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +55 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +68 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +29 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/chartAnnotations.ts +326 -0
  180. package/app/utils/countries.ts +337 -0
  181. package/app/utils/export-download.ts +100 -0
  182. package/app/utils/favicon-fallback.ts +12 -0
  183. package/app/utils/icon-color.ts +105 -0
  184. package/app/utils/number.ts +74 -0
  185. package/app/utils/render-link.ts +14 -0
  186. package/app/utils/slot-text.ts +18 -0
  187. package/app/utils/squircle.ts +154 -0
  188. package/app/utils/topEntityStack.ts +197 -0
  189. package/app/utils/vue.ts +57 -0
  190. package/build/design-system.ts +47 -0
  191. package/build/icon-server-bundle.ts +276 -0
  192. package/build/runtime/icon-server-bundle.ts +29 -0
  193. package/css/global.css +1892 -0
  194. package/nuxt.config.ts +132 -0
  195. package/package.json +86 -3
  196. package/public/brand-icon-dark.png +0 -0
  197. package/public/brand-icon-light.png +0 -0
  198. package/public/favicon-dark.png +0 -0
  199. package/public/favicon-light.png +0 -0
  200. package/public/favicon.ico +0 -0
  201. package/public/fonts/README.md +31 -0
  202. package/public/fonts/fira-code-OFL.txt +93 -0
  203. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  204. package/public/fonts/hubot-sans-OFL.txt +93 -0
  205. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  206. package/public/icon-dark.png +0 -0
  207. package/public/icon.png +0 -0
  208. package/public/touch-icon-dark.png +0 -0
  209. package/public/touch-icon.png +0 -0
  210. package/server/api/favicon.get.ts +122 -0
  211. package/server/utils/favicon-source.ts +51 -0
  212. package/shared/contracts/feedback.ts +42 -0
  213. package/shared/contracts/index.ts +1 -0
  214. package/shared/delivery-channels.ts +53 -0
  215. package/shared/icons/index.ts +143 -0
  216. package/shared/icons/registry.ts +347 -0
  217. package/shared/icons/ui-slots.ts +58 -0
  218. package/shared/markdown.ts +44 -0
  219. package/shared/motion.ts +120 -0
  220. package/shared/rangi.ts +60 -0
  221. package/shared/rich-text.ts +34 -0
  222. package/shared/ui-icons.ts +6 -0
  223. package/shared/utils/urls.ts +64 -0
  224. package/types/index.ts +1 -0
  225. package/types/ui.ts +339 -0
@@ -0,0 +1,326 @@
1
+ // Pure helpers for chart annotations — the day-key matching + marker positioning
2
+ // extracted from UiChartFrame so they're unit-testable and shareable by every
3
+ // annotated chart (UiChartFrame's tooltip, the UiChartAnnotations marker overlay,
4
+ // and the CWV / indexing charts that compose unovis directly).
5
+
6
+ /**
7
+ * A single chart annotation — a thin vertical marker line + interactive dot.
8
+ * A plain UI shape any chart consumer can populate from any data source
9
+ * (timeline events, deploy markers, …); the design system never imports the
10
+ * domain that produces it.
11
+ */
12
+ export interface ChartAnnotation {
13
+ /** X position as a Date, ISO string, or epoch-ms number matching the chart's x domain. */
14
+ x: number | string | Date
15
+ /** Short label shown in the marker's native title + the chart tooltip. */
16
+ label: string
17
+ /** Optional producer-owned explanation or value shown below the label. */
18
+ description?: string
19
+ /**
20
+ * Semantic tone that tints the marker (semantic CSS classes only).
21
+ * 'success' → bg-success 'error' → bg-error 'warning' → bg-warning
22
+ * 'neutral' (default) → bg-accented
23
+ */
24
+ tone?: 'success' | 'error' | 'warning' | 'neutral'
25
+ /** Compact semantic or explicitly sourced icon rendered beside the marker dot. */
26
+ icon?: string
27
+ /** Stable producer id — lets marker affordances (edit/delete) address the row. */
28
+ id?: string
29
+ /** Optional link opened from the marker tooltip. Only an http(s) URL renders; any other scheme is dropped. */
30
+ href?: string
31
+ /**
32
+ * User-authored marker: rendered with the editable glyph (square vs dot) and
33
+ * offered edit/delete affordances by consumers that support them.
34
+ */
35
+ editable?: boolean
36
+ /** Producer category token (e.g. 'release', 'custom') — opaque to the design system. */
37
+ category?: string
38
+ /** Original reporting day retained when a date annotation is re-anchored to an index x-axis. */
39
+ dayKey?: string
40
+ /** Optional producer image shown under the description, such as the page as the change left it. */
41
+ image?: { src: string, alt: string }
42
+ }
43
+
44
+ /**
45
+ * What ONE chart accepts, on top of whatever categories the caller treats as
46
+ * global (every chart gets those for free — see `annotationInScope`'s
47
+ * `rules.globalCategories`). Stays domain-opaque like `ChartAnnotation`
48
+ * itself: the design system knows the SHAPE of a per-chart declaration, never
49
+ * which category/kind strings a producer actually uses.
50
+ */
51
+ export interface ChartAnnotationScope {
52
+ /** Non-global categories this chart accepts (e.g. an 'algo-update' producer). */
53
+ categories: readonly string[]
54
+ /** Topic allow-list scoped to `rules.kindBearingCategory`. Omit to accept every kind within it. */
55
+ kinds?: readonly string[]
56
+ }
57
+
58
+ /** The two facts a caller supplies about its own category vocabulary — see `annotationInScope`. */
59
+ export interface ChartAnnotationScopeRules {
60
+ /** Categories every chart accepts regardless of `scope` (e.g. user notes, releases). */
61
+ globalCategories: readonly string[]
62
+ /** The one category whose annotations carry a producer `kind` for topic-level filtering. */
63
+ kindBearingCategory: string
64
+ }
65
+
66
+ /**
67
+ * True when `annotation` should render on a chart declaring `scope`. A chart
68
+ * silently accepting every category it was never asked for is the bug this
69
+ * closes (manual-review-2026-08 §Annotations: Search Console algorithm
70
+ * updates rendering on a performance chart) — a category outside both
71
+ * `rules.globalCategories` and `scope.categories` is rejected, full stop.
72
+ */
73
+ export function annotationInScope(
74
+ annotation: { category?: string, kind?: string },
75
+ scope: ChartAnnotationScope,
76
+ rules: ChartAnnotationScopeRules,
77
+ ): boolean {
78
+ const category = annotation.category
79
+ if (category == null || rules.globalCategories.includes(category))
80
+ return true
81
+ if (!scope.categories.includes(category))
82
+ return false
83
+ if (category === rules.kindBearingCategory && scope.kinds)
84
+ return annotation.kind != null && scope.kinds.includes(annotation.kind)
85
+ return true
86
+ }
87
+
88
+ /** Marker icon count. Three is the visual ceiling; zero shows only the overflow count. */
89
+ export type ChartAnnotationIconLimit = 0 | 1 | 2 | 3
90
+
91
+ export interface ChartAnnotationOptions {
92
+ /** Annotation icons shown beside the marker before a +N overflow count. Default 3. */
93
+ maxIcons?: ChartAnnotationIconLimit
94
+ }
95
+
96
+ export interface ChartAnnotationControlItem<K extends string = string> {
97
+ key: K
98
+ label: string
99
+ icon: string
100
+ count?: number
101
+ enabled: boolean
102
+ }
103
+
104
+ export const DEFAULT_ANNOTATION_ICON_LIMIT: ChartAnnotationIconLimit = 3
105
+
106
+ export function annotationIconPreview(
107
+ items: readonly ChartAnnotation[],
108
+ maxIcons: ChartAnnotationIconLimit = DEFAULT_ANNOTATION_ICON_LIMIT,
109
+ ): { visible: readonly ChartAnnotation[], overflowCount: number } {
110
+ return {
111
+ visible: items.slice(0, maxIcons),
112
+ overflowCount: Math.max(0, items.length - maxIcons),
113
+ }
114
+ }
115
+
116
+ /** Semantic tone → marker tailwind background class (line + dot). */
117
+ export const ANNOTATION_TONE_CLASS: Record<NonNullable<ChartAnnotation['tone']>, string> = {
118
+ success: 'bg-success',
119
+ error: 'bg-error',
120
+ warning: 'bg-warning',
121
+ neutral: 'bg-accented',
122
+ }
123
+
124
+ function toMs(v: Date | string | number): number {
125
+ if (v instanceof Date)
126
+ return v.getTime()
127
+ if (typeof v === 'number')
128
+ return v
129
+ return new Date(v).getTime()
130
+ }
131
+
132
+ /**
133
+ * Normalise any x value to a `YYYY-MM-DD` day key.
134
+ *
135
+ * A `YYYY-MM-DD…` string keeps its own day verbatim (no Date round-trip, so no
136
+ * UTC shift); a `Date`/epoch-ms is keyed by its **UTC** day.
137
+ *
138
+ * TIMEZONE NOTE (ADR-0082): chart x-domains here are GSC reporting days
139
+ * (Pacific) rendered as UTC-midnight Dates, and an annotation's `x` is typically
140
+ * an event instant (`occurredAt`, a UTC Date). Marker placement and the tooltip
141
+ * match BOTH key off that UTC day, so the tooltip always matches the day the
142
+ * marker is drawn on — they stay mutually consistent. The only artefact: a
143
+ * detection-time event late in the Pacific evening keys one calendar day ahead
144
+ * of the viewer's local day, and its marker sits on that same (next) day.
145
+ */
146
+ export function toChartDayKey(v: Date | string | number): string | null {
147
+ if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v))
148
+ return v.slice(0, 10)
149
+ // A non-date x value (a category axis, "Mobile") has no day. `toISOString`
150
+ // throws a RangeError on an Invalid Date, which failed the whole chart render.
151
+ const ms = toMs(v)
152
+ return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : null
153
+ }
154
+
155
+ /** Annotations whose day matches the hovered x value (same `YYYY-MM-DD`). */
156
+ export function annotationsOnDay<T extends { x: Date | string | number }>(
157
+ annotations: readonly T[],
158
+ hoverX: Date | string | number | null | undefined,
159
+ ): T[] {
160
+ if (hoverX == null || !annotations.length)
161
+ return []
162
+ const day = toChartDayKey(hoverX)
163
+ if (!day)
164
+ return []
165
+ return annotations.filter(a => toChartDayKey(a.x) === day)
166
+ }
167
+
168
+ /**
169
+ * The cheap diagnose line for a marker tooltip: "N days later, <metric> +X%".
170
+ * Computed from the series already on screen (3-day means around the marker
171
+ * day and `days` later) — the render-time half of the ROADMAP "tether" idea,
172
+ * with zero backend. Returns undefined when the series doesn't extend far
173
+ * enough past the marker or the baseline is zero.
174
+ */
175
+ export function annotationImpactLine(
176
+ series: readonly { date: string, value: number }[],
177
+ markerX: Date | string | number,
178
+ metricLabel: string,
179
+ days = 7,
180
+ ): string | undefined {
181
+ if (series.length < days + 1)
182
+ return undefined
183
+ const day = toChartDayKey(markerX)
184
+ if (!day)
185
+ return undefined
186
+ const anchor = series.findIndex((row) => {
187
+ const rowDay = toChartDayKey(row.date)
188
+ return rowDay != null && rowDay >= day
189
+ })
190
+ if (anchor < 1 || anchor + days >= series.length)
191
+ return undefined
192
+ const mean = (end: number) => {
193
+ const slice = series.slice(Math.max(0, end - 2), end + 1)
194
+ return slice.reduce((sum, row) => sum + row.value, 0) / slice.length
195
+ }
196
+ // Baseline ends the day BEFORE the marker — the marker day itself already
197
+ // carries the event's effect and would dilute the delta.
198
+ const before = mean(anchor - 1)
199
+ const after = mean(anchor + days)
200
+ if (before <= 0)
201
+ return undefined
202
+ const pct = Math.round(((after - before) / before) * 100)
203
+ if (!Number.isFinite(pct))
204
+ return undefined
205
+ return `${days} days later, ${metricLabel} ${pct >= 0 ? '+' : ''}${pct}%`
206
+ }
207
+
208
+ /**
209
+ * The CSS `left` percentage for an annotation within a zero-horizontal-margin
210
+ * plot (wrap width == plot x-span). Clamped to [0,100]. Returns '50%' for a
211
+ * degenerate domain.
212
+ */
213
+ export function annotationLeftPct(x: Date | string | number, domain: [Date | string | number, Date | string | number]): string {
214
+ const xMs = toMs(x)
215
+ const minMs = toMs(domain[0])
216
+ const maxMs = toMs(domain[1])
217
+ if (maxMs <= minMs)
218
+ return '50%'
219
+ const pct = Math.max(0, Math.min(100, ((xMs - minMs) / (maxMs - minMs)) * 100))
220
+ return `${pct.toFixed(4)}%`
221
+ }
222
+
223
+ /**
224
+ * Re-anchor annotations onto an INDEX-based x-axis (`x = (_d, i) => i`, e.g. the
225
+ * CWV + indexing charts, whose bars are evenly spaced by array index rather than
226
+ * by time). Maps each annotation's day to the data index sharing that day and
227
+ * drops annotations with no matching data point. Pair with `xDomain = [0, len-1]`
228
+ * so `UiChartAnnotations` positions markers on the right bar.
229
+ */
230
+ export function indexAnnotationsByDay(
231
+ annotations: readonly ChartAnnotation[] | undefined,
232
+ days: readonly (string | null)[],
233
+ ): ChartAnnotation[] {
234
+ if (!annotations?.length || !days.length)
235
+ return []
236
+ const dayToIndex = new Map<string, number>()
237
+ days.forEach((d, i) => {
238
+ const key = d == null ? null : toChartDayKey(d)
239
+ if (key && !dayToIndex.has(key))
240
+ dayToIndex.set(key, i)
241
+ })
242
+ return annotations.flatMap((a) => {
243
+ const key = toChartDayKey(a.x)
244
+ const i = key ? dayToIndex.get(key) : undefined
245
+ return key == null || i == null ? [] : [{ ...a, x: i, dayKey: key }]
246
+ })
247
+ }
248
+
249
+ export interface ResolvedAnnotationMarker {
250
+ id: number
251
+ label: string
252
+ description?: string
253
+ leftPct: string
254
+ toneClass: string
255
+ }
256
+
257
+ /** Resolve annotations to positioned markers; [] when no domain (silently skipped). */
258
+ export interface ResolvedAnnotationMarkerGroup {
259
+ /** The shared day key (`YYYY-MM-DD`) — one marker per day. */
260
+ id: string
261
+ leftPct: string
262
+ /** Dominant tone across the day's annotations (error > warning > success > neutral). */
263
+ toneClass: string
264
+ count: number
265
+ /** True when any of the day's annotations is user-editable. */
266
+ editable: boolean
267
+ items: ChartAnnotation[]
268
+ }
269
+
270
+ const TONE_RANK: Record<NonNullable<ChartAnnotation['tone']>, number> = {
271
+ error: 3,
272
+ warning: 2,
273
+ success: 1,
274
+ neutral: 0,
275
+ }
276
+
277
+ /**
278
+ * Day-grouped markers: annotations sharing a calendar day collapse into ONE
279
+ * marker (a "+N" count pill signals the stack; the tooltip lists every item)
280
+ * instead of piling identical lines on the same pixel column.
281
+ */
282
+ export function resolveAnnotationMarkerGroups(
283
+ annotations: readonly ChartAnnotation[] | undefined,
284
+ xDomain: [Date | string | number, Date | string | number] | undefined,
285
+ ): ResolvedAnnotationMarkerGroup[] {
286
+ if (!annotations?.length || !xDomain)
287
+ return []
288
+ const byDay = new Map<string, ChartAnnotation[]>()
289
+ for (const ann of annotations) {
290
+ // An index-anchored annotation always carries its dayKey. Without one, a
291
+ // non-date x still renders as its own marker, keyed by its raw value.
292
+ const day = ann.dayKey ?? toChartDayKey(ann.x) ?? String(ann.x)
293
+ const bucket = byDay.get(day)
294
+ if (bucket)
295
+ bucket.push(ann)
296
+ else
297
+ byDay.set(day, [ann])
298
+ }
299
+ return Array.from(byDay.entries(), ([day, items]) => {
300
+ const tone = items.reduce<NonNullable<ChartAnnotation['tone']>>((acc, item) =>
301
+ TONE_RANK[item.tone ?? 'neutral'] > TONE_RANK[acc] ? (item.tone ?? 'neutral') : acc, 'neutral')
302
+ return {
303
+ id: day,
304
+ leftPct: annotationLeftPct(items[0]!.x, xDomain),
305
+ toneClass: ANNOTATION_TONE_CLASS[tone],
306
+ count: items.length,
307
+ editable: items.some(item => item.editable === true),
308
+ items,
309
+ }
310
+ })
311
+ }
312
+
313
+ export function resolveAnnotationMarkers(
314
+ annotations: readonly ChartAnnotation[] | undefined,
315
+ xDomain: [Date | string | number, Date | string | number] | undefined,
316
+ ): ResolvedAnnotationMarker[] {
317
+ if (!annotations?.length || !xDomain)
318
+ return []
319
+ return annotations.map((ann, i) => ({
320
+ id: i,
321
+ label: ann.label,
322
+ ...(ann.description ? { description: ann.description } : {}),
323
+ leftPct: annotationLeftPct(ann.x, xDomain),
324
+ toneClass: ANNOTATION_TONE_CLASS[ann.tone ?? 'neutral'],
325
+ }))
326
+ }
@@ -0,0 +1,337 @@
1
+ import type { UiIcon } from '../shared/ui-icons'
2
+ import { DISPLAY_LOCALE } from '../composables/formatting'
3
+
4
+ // ISO 3166-1 alpha-3 to alpha-2 and country names
5
+ export const countryData: Record<string, { alpha2: string, name: string }> = {
6
+ deu: { alpha2: 'de', name: 'Germany' },
7
+ fra: { alpha2: 'fr', name: 'France' },
8
+ ind: { alpha2: 'in', name: 'India' },
9
+ usa: { alpha2: 'us', name: 'United States' },
10
+ gbr: { alpha2: 'gb', name: 'United Kingdom' },
11
+ nld: { alpha2: 'nl', name: 'Netherlands' },
12
+ pol: { alpha2: 'pl', name: 'Poland' },
13
+ rus: { alpha2: 'ru', name: 'Russia' },
14
+ esp: { alpha2: 'es', name: 'Spain' },
15
+ ita: { alpha2: 'it', name: 'Italy' },
16
+ bra: { alpha2: 'br', name: 'Brazil' },
17
+ can: { alpha2: 'ca', name: 'Canada' },
18
+ aus: { alpha2: 'au', name: 'Australia' },
19
+ jpn: { alpha2: 'jp', name: 'Japan' },
20
+ chn: { alpha2: 'cn', name: 'China' },
21
+ kor: { alpha2: 'kr', name: 'South Korea' },
22
+ mex: { alpha2: 'mx', name: 'Mexico' },
23
+ arg: { alpha2: 'ar', name: 'Argentina' },
24
+ tur: { alpha2: 'tr', name: 'Turkey' },
25
+ sau: { alpha2: 'sa', name: 'Saudi Arabia' },
26
+ zaf: { alpha2: 'za', name: 'South Africa' },
27
+ egy: { alpha2: 'eg', name: 'Egypt' },
28
+ nga: { alpha2: 'ng', name: 'Nigeria' },
29
+ ken: { alpha2: 'ke', name: 'Kenya' },
30
+ idn: { alpha2: 'id', name: 'Indonesia' },
31
+ tha: { alpha2: 'th', name: 'Thailand' },
32
+ vnm: { alpha2: 'vn', name: 'Vietnam' },
33
+ phl: { alpha2: 'ph', name: 'Philippines' },
34
+ mys: { alpha2: 'my', name: 'Malaysia' },
35
+ sgp: { alpha2: 'sg', name: 'Singapore' },
36
+ pak: { alpha2: 'pk', name: 'Pakistan' },
37
+ bgd: { alpha2: 'bd', name: 'Bangladesh' },
38
+ ukr: { alpha2: 'ua', name: 'Ukraine' },
39
+ rou: { alpha2: 'ro', name: 'Romania' },
40
+ cze: { alpha2: 'cz', name: 'Czechia' },
41
+ hun: { alpha2: 'hu', name: 'Hungary' },
42
+ aut: { alpha2: 'at', name: 'Austria' },
43
+ che: { alpha2: 'ch', name: 'Switzerland' },
44
+ bel: { alpha2: 'be', name: 'Belgium' },
45
+ swe: { alpha2: 'se', name: 'Sweden' },
46
+ nor: { alpha2: 'no', name: 'Norway' },
47
+ dnk: { alpha2: 'dk', name: 'Denmark' },
48
+ fin: { alpha2: 'fi', name: 'Finland' },
49
+ prt: { alpha2: 'pt', name: 'Portugal' },
50
+ grc: { alpha2: 'gr', name: 'Greece' },
51
+ irl: { alpha2: 'ie', name: 'Ireland' },
52
+ nzl: { alpha2: 'nz', name: 'New Zealand' },
53
+ isr: { alpha2: 'il', name: 'Israel' },
54
+ are: { alpha2: 'ae', name: 'UAE' },
55
+ col: { alpha2: 'co', name: 'Colombia' },
56
+ chl: { alpha2: 'cl', name: 'Chile' },
57
+ per: { alpha2: 'pe', name: 'Peru' },
58
+ ven: { alpha2: 've', name: 'Venezuela' },
59
+ irn: { alpha2: 'ir', name: 'Iran' },
60
+ irq: { alpha2: 'iq', name: 'Iraq' },
61
+ twn: { alpha2: 'tw', name: 'Taiwan' },
62
+ hkg: { alpha2: 'hk', name: 'Hong Kong' },
63
+ mac: { alpha2: 'mo', name: 'Macau' },
64
+ svk: { alpha2: 'sk', name: 'Slovakia' },
65
+ bgr: { alpha2: 'bg', name: 'Bulgaria' },
66
+ hrv: { alpha2: 'hr', name: 'Croatia' },
67
+ srb: { alpha2: 'rs', name: 'Serbia' },
68
+ svn: { alpha2: 'si', name: 'Slovenia' },
69
+ ltu: { alpha2: 'lt', name: 'Lithuania' },
70
+ lva: { alpha2: 'lv', name: 'Latvia' },
71
+ est: { alpha2: 'ee', name: 'Estonia' },
72
+ blr: { alpha2: 'by', name: 'Belarus' },
73
+ kaz: { alpha2: 'kz', name: 'Kazakhstan' },
74
+ uzb: { alpha2: 'uz', name: 'Uzbekistan' },
75
+ aze: { alpha2: 'az', name: 'Azerbaijan' },
76
+ geo: { alpha2: 'ge', name: 'Georgia' },
77
+ arm: { alpha2: 'am', name: 'Armenia' },
78
+ // Remaining ISO 3166-1 countries + common GSC-reported territories. GSC emits
79
+ // alpha-3 (lowercase) for every country, so an incomplete map left the long
80
+ // tail rendering as raw codes (`lbn`, `isl`) with a globe instead of a flag.
81
+ afg: { alpha2: 'af', name: 'Afghanistan' },
82
+ alb: { alpha2: 'al', name: 'Albania' },
83
+ dza: { alpha2: 'dz', name: 'Algeria' },
84
+ and: { alpha2: 'ad', name: 'Andorra' },
85
+ ago: { alpha2: 'ao', name: 'Angola' },
86
+ atg: { alpha2: 'ag', name: 'Antigua and Barbuda' },
87
+ bhs: { alpha2: 'bs', name: 'Bahamas' },
88
+ bhr: { alpha2: 'bh', name: 'Bahrain' },
89
+ brb: { alpha2: 'bb', name: 'Barbados' },
90
+ ben: { alpha2: 'bj', name: 'Benin' },
91
+ btn: { alpha2: 'bt', name: 'Bhutan' },
92
+ bol: { alpha2: 'bo', name: 'Bolivia' },
93
+ bih: { alpha2: 'ba', name: 'Bosnia and Herzegovina' },
94
+ bwa: { alpha2: 'bw', name: 'Botswana' },
95
+ brn: { alpha2: 'bn', name: 'Brunei' },
96
+ bfa: { alpha2: 'bf', name: 'Burkina Faso' },
97
+ bdi: { alpha2: 'bi', name: 'Burundi' },
98
+ khm: { alpha2: 'kh', name: 'Cambodia' },
99
+ cmr: { alpha2: 'cm', name: 'Cameroon' },
100
+ cpv: { alpha2: 'cv', name: 'Cape Verde' },
101
+ caf: { alpha2: 'cf', name: 'Central African Republic' },
102
+ tcd: { alpha2: 'td', name: 'Chad' },
103
+ com: { alpha2: 'km', name: 'Comoros' },
104
+ cog: { alpha2: 'cg', name: 'Congo' },
105
+ cod: { alpha2: 'cd', name: 'DR Congo' },
106
+ cri: { alpha2: 'cr', name: 'Costa Rica' },
107
+ civ: { alpha2: 'ci', name: 'Côte d\'Ivoire' },
108
+ cub: { alpha2: 'cu', name: 'Cuba' },
109
+ cyp: { alpha2: 'cy', name: 'Cyprus' },
110
+ dji: { alpha2: 'dj', name: 'Djibouti' },
111
+ dma: { alpha2: 'dm', name: 'Dominica' },
112
+ dom: { alpha2: 'do', name: 'Dominican Republic' },
113
+ ecu: { alpha2: 'ec', name: 'Ecuador' },
114
+ slv: { alpha2: 'sv', name: 'El Salvador' },
115
+ gnq: { alpha2: 'gq', name: 'Equatorial Guinea' },
116
+ eri: { alpha2: 'er', name: 'Eritrea' },
117
+ swz: { alpha2: 'sz', name: 'Eswatini' },
118
+ eth: { alpha2: 'et', name: 'Ethiopia' },
119
+ fji: { alpha2: 'fj', name: 'Fiji' },
120
+ gab: { alpha2: 'ga', name: 'Gabon' },
121
+ gmb: { alpha2: 'gm', name: 'Gambia' },
122
+ gha: { alpha2: 'gh', name: 'Ghana' },
123
+ gib: { alpha2: 'gi', name: 'Gibraltar' },
124
+ grd: { alpha2: 'gd', name: 'Grenada' },
125
+ gtm: { alpha2: 'gt', name: 'Guatemala' },
126
+ gin: { alpha2: 'gn', name: 'Guinea' },
127
+ gnb: { alpha2: 'gw', name: 'Guinea-Bissau' },
128
+ guy: { alpha2: 'gy', name: 'Guyana' },
129
+ hti: { alpha2: 'ht', name: 'Haiti' },
130
+ hnd: { alpha2: 'hn', name: 'Honduras' },
131
+ isl: { alpha2: 'is', name: 'Iceland' },
132
+ jam: { alpha2: 'jm', name: 'Jamaica' },
133
+ jor: { alpha2: 'jo', name: 'Jordan' },
134
+ kwt: { alpha2: 'kw', name: 'Kuwait' },
135
+ kgz: { alpha2: 'kg', name: 'Kyrgyzstan' },
136
+ lao: { alpha2: 'la', name: 'Laos' },
137
+ lbn: { alpha2: 'lb', name: 'Lebanon' },
138
+ lso: { alpha2: 'ls', name: 'Lesotho' },
139
+ lbr: { alpha2: 'lr', name: 'Liberia' },
140
+ lby: { alpha2: 'ly', name: 'Libya' },
141
+ lie: { alpha2: 'li', name: 'Liechtenstein' },
142
+ lux: { alpha2: 'lu', name: 'Luxembourg' },
143
+ mkd: { alpha2: 'mk', name: 'North Macedonia' },
144
+ mdg: { alpha2: 'mg', name: 'Madagascar' },
145
+ mwi: { alpha2: 'mw', name: 'Malawi' },
146
+ mdv: { alpha2: 'mv', name: 'Maldives' },
147
+ mli: { alpha2: 'ml', name: 'Mali' },
148
+ mlt: { alpha2: 'mt', name: 'Malta' },
149
+ mrt: { alpha2: 'mr', name: 'Mauritania' },
150
+ mus: { alpha2: 'mu', name: 'Mauritius' },
151
+ mda: { alpha2: 'md', name: 'Moldova' },
152
+ mco: { alpha2: 'mc', name: 'Monaco' },
153
+ mng: { alpha2: 'mn', name: 'Mongolia' },
154
+ mne: { alpha2: 'me', name: 'Montenegro' },
155
+ mar: { alpha2: 'ma', name: 'Morocco' },
156
+ moz: { alpha2: 'mz', name: 'Mozambique' },
157
+ mmr: { alpha2: 'mm', name: 'Myanmar' },
158
+ nam: { alpha2: 'na', name: 'Namibia' },
159
+ npl: { alpha2: 'np', name: 'Nepal' },
160
+ nic: { alpha2: 'ni', name: 'Nicaragua' },
161
+ ner: { alpha2: 'ne', name: 'Niger' },
162
+ omn: { alpha2: 'om', name: 'Oman' },
163
+ pan: { alpha2: 'pa', name: 'Panama' },
164
+ png: { alpha2: 'pg', name: 'Papua New Guinea' },
165
+ pry: { alpha2: 'py', name: 'Paraguay' },
166
+ pse: { alpha2: 'ps', name: 'Palestine' },
167
+ pri: { alpha2: 'pr', name: 'Puerto Rico' },
168
+ qat: { alpha2: 'qa', name: 'Qatar' },
169
+ rwa: { alpha2: 'rw', name: 'Rwanda' },
170
+ kna: { alpha2: 'kn', name: 'Saint Kitts and Nevis' },
171
+ lca: { alpha2: 'lc', name: 'Saint Lucia' },
172
+ vct: { alpha2: 'vc', name: 'Saint Vincent and the Grenadines' },
173
+ wsm: { alpha2: 'ws', name: 'Samoa' },
174
+ smr: { alpha2: 'sm', name: 'San Marino' },
175
+ stp: { alpha2: 'st', name: 'São Tomé and Príncipe' },
176
+ sen: { alpha2: 'sn', name: 'Senegal' },
177
+ syc: { alpha2: 'sc', name: 'Seychelles' },
178
+ sle: { alpha2: 'sl', name: 'Sierra Leone' },
179
+ slb: { alpha2: 'sb', name: 'Solomon Islands' },
180
+ som: { alpha2: 'so', name: 'Somalia' },
181
+ ssd: { alpha2: 'ss', name: 'South Sudan' },
182
+ lka: { alpha2: 'lk', name: 'Sri Lanka' },
183
+ sdn: { alpha2: 'sd', name: 'Sudan' },
184
+ sur: { alpha2: 'sr', name: 'Suriname' },
185
+ syr: { alpha2: 'sy', name: 'Syria' },
186
+ tjk: { alpha2: 'tj', name: 'Tajikistan' },
187
+ tza: { alpha2: 'tz', name: 'Tanzania' },
188
+ tls: { alpha2: 'tl', name: 'Timor-Leste' },
189
+ tgo: { alpha2: 'tg', name: 'Togo' },
190
+ ton: { alpha2: 'to', name: 'Tonga' },
191
+ tto: { alpha2: 'tt', name: 'Trinidad and Tobago' },
192
+ tun: { alpha2: 'tn', name: 'Tunisia' },
193
+ tkm: { alpha2: 'tm', name: 'Turkmenistan' },
194
+ uga: { alpha2: 'ug', name: 'Uganda' },
195
+ ury: { alpha2: 'uy', name: 'Uruguay' },
196
+ vut: { alpha2: 'vu', name: 'Vanuatu' },
197
+ yem: { alpha2: 'ye', name: 'Yemen' },
198
+ zmb: { alpha2: 'zm', name: 'Zambia' },
199
+ zwe: { alpha2: 'zw', name: 'Zimbabwe' },
200
+ // Dependencies, overseas territories and the remaining sovereign states the
201
+ // list above skipped. GSC reports on the full ISO 3166-1 set, so anything
202
+ // absent here rendered as a raw id (`reu`, `blz`) beside a globe. `name` is
203
+ // the offline fallback only — `getCountryName` prefers `Intl.DisplayNames`,
204
+ // and these English strings were generated FROM that same CLDR data so the
205
+ // two agree. Every alpha-2 below has a `i-circle-flags-*` glyph (checked
206
+ // against @iconify-json/circle-flags).
207
+ abw: { alpha2: 'aw', name: 'Aruba' },
208
+ aia: { alpha2: 'ai', name: 'Anguilla' },
209
+ ala: { alpha2: 'ax', name: 'Åland Islands' },
210
+ asm: { alpha2: 'as', name: 'American Samoa' },
211
+ ata: { alpha2: 'aq', name: 'Antarctica' },
212
+ atf: { alpha2: 'tf', name: 'French Southern Territories' },
213
+ bes: { alpha2: 'bq', name: 'Caribbean Netherlands' },
214
+ blm: { alpha2: 'bl', name: 'St. Barthélemy' },
215
+ blz: { alpha2: 'bz', name: 'Belize' },
216
+ bmu: { alpha2: 'bm', name: 'Bermuda' },
217
+ bvt: { alpha2: 'bv', name: 'Bouvet Island' },
218
+ cck: { alpha2: 'cc', name: 'Cocos (Keeling) Islands' },
219
+ cok: { alpha2: 'ck', name: 'Cook Islands' },
220
+ cuw: { alpha2: 'cw', name: 'Curaçao' },
221
+ cxr: { alpha2: 'cx', name: 'Christmas Island' },
222
+ cym: { alpha2: 'ky', name: 'Cayman Islands' },
223
+ esh: { alpha2: 'eh', name: 'Western Sahara' },
224
+ flk: { alpha2: 'fk', name: 'Falkland Islands' },
225
+ fro: { alpha2: 'fo', name: 'Faroe Islands' },
226
+ fsm: { alpha2: 'fm', name: 'Micronesia' },
227
+ ggy: { alpha2: 'gg', name: 'Guernsey' },
228
+ glp: { alpha2: 'gp', name: 'Guadeloupe' },
229
+ grl: { alpha2: 'gl', name: 'Greenland' },
230
+ guf: { alpha2: 'gf', name: 'French Guiana' },
231
+ gum: { alpha2: 'gu', name: 'Guam' },
232
+ hmd: { alpha2: 'hm', name: 'Heard & McDonald Islands' },
233
+ imn: { alpha2: 'im', name: 'Isle of Man' },
234
+ iot: { alpha2: 'io', name: 'British Indian Ocean Territory' },
235
+ jey: { alpha2: 'je', name: 'Jersey' },
236
+ kir: { alpha2: 'ki', name: 'Kiribati' },
237
+ maf: { alpha2: 'mf', name: 'St. Martin' },
238
+ mhl: { alpha2: 'mh', name: 'Marshall Islands' },
239
+ mnp: { alpha2: 'mp', name: 'Northern Mariana Islands' },
240
+ msr: { alpha2: 'ms', name: 'Montserrat' },
241
+ mtq: { alpha2: 'mq', name: 'Martinique' },
242
+ myt: { alpha2: 'yt', name: 'Mayotte' },
243
+ ncl: { alpha2: 'nc', name: 'New Caledonia' },
244
+ nfk: { alpha2: 'nf', name: 'Norfolk Island' },
245
+ niu: { alpha2: 'nu', name: 'Niue' },
246
+ nru: { alpha2: 'nr', name: 'Nauru' },
247
+ pcn: { alpha2: 'pn', name: 'Pitcairn Islands' },
248
+ plw: { alpha2: 'pw', name: 'Palau' },
249
+ prk: { alpha2: 'kp', name: 'North Korea' },
250
+ pyf: { alpha2: 'pf', name: 'French Polynesia' },
251
+ reu: { alpha2: 're', name: 'Réunion' },
252
+ sgs: { alpha2: 'gs', name: 'South Georgia & South Sandwich Islands' },
253
+ shn: { alpha2: 'sh', name: 'St. Helena' },
254
+ sjm: { alpha2: 'sj', name: 'Svalbard & Jan Mayen' },
255
+ spm: { alpha2: 'pm', name: 'St. Pierre & Miquelon' },
256
+ sxm: { alpha2: 'sx', name: 'Sint Maarten' },
257
+ tca: { alpha2: 'tc', name: 'Turks & Caicos Islands' },
258
+ tkl: { alpha2: 'tk', name: 'Tokelau' },
259
+ tuv: { alpha2: 'tv', name: 'Tuvalu' },
260
+ umi: { alpha2: 'um', name: 'U.S. Outlying Islands' },
261
+ vat: { alpha2: 'va', name: 'Vatican City' },
262
+ vgb: { alpha2: 'vg', name: 'British Virgin Islands' },
263
+ vir: { alpha2: 'vi', name: 'U.S. Virgin Islands' },
264
+ wlf: { alpha2: 'wf', name: 'Wallis & Futuna' },
265
+ // Kosovo has no ISO 3166-1 assignment; GSC emits the user-assigned `xkk`,
266
+ // which resolves to the `xk` region CLDR and Iconify both carry. Kept to the
267
+ // one spelling GSC actually sends — a second alias would render as a
268
+ // duplicate "Kosovo" row in every dropdown built from `Object.values`.
269
+ xkk: { alpha2: 'xk', name: 'Kosovo' },
270
+ // GSC's sentinel for traffic it cannot attribute to a region. It appears in
271
+ // essentially every property and is a real category, not an error — so it
272
+ // gets a name, and `UNKNOWN_REGION_CODES` keeps it off the flag path.
273
+ zzz: { alpha2: 'zz', name: 'Unknown region' },
274
+ }
275
+
276
+ /** Codes that name "we don't know", in every spelling a caller might pass. */
277
+ const UNKNOWN_REGION_CODES = new Set(['zz', 'zzz'])
278
+
279
+ // Convert country code to circle-flags icon name
280
+ export function countryCodeToFlagIcon(code: string): UiIcon {
281
+ if (!code)
282
+ return 'globe'
283
+ const lowerCode = code.toLowerCase()
284
+ // There is no `zz` flag (nor should there be) — the globe IS the answer.
285
+ if (UNKNOWN_REGION_CODES.has(lowerCode))
286
+ return 'globe'
287
+ const data = countryData[lowerCode]
288
+ const alpha2 = data?.alpha2 || (lowerCode.length === 2 ? lowerCode : null)
289
+ if (!alpha2)
290
+ return 'globe'
291
+ return `i-circle-flags-${alpha2}`
292
+ }
293
+
294
+ // Localized region-name lookup. The bundled `name` field is English-only, so
295
+ // prefer `Intl.DisplayNames` in the pinned display locale (formatting.ts): the
296
+ // runtime locale differs between server and browser and broke hydration
297
+ // and fall back to the map → raw code when the runtime lacks region data.
298
+ let regionDisplay: Intl.DisplayNames | null | undefined
299
+ function getRegionDisplay(): Intl.DisplayNames | null {
300
+ if (regionDisplay !== undefined)
301
+ return regionDisplay
302
+ try {
303
+ regionDisplay = new Intl.DisplayNames(DISPLAY_LOCALE, { type: 'region' })
304
+ }
305
+ catch {
306
+ regionDisplay = null // runtime without region display data
307
+ }
308
+ return regionDisplay
309
+ }
310
+
311
+ // Get country display name from code
312
+ export function getCountryName(code: string, fallback?: string): string {
313
+ if (!code)
314
+ return fallback || 'Unknown'
315
+ const lowerCode = code.toLowerCase()
316
+ // Ahead of the Intl lookup on purpose: CLDR titles this "Unknown Region",
317
+ // which reads as a proper noun in a list of real ones.
318
+ if (UNKNOWN_REGION_CODES.has(lowerCode))
319
+ return 'Unknown region'
320
+ const data = countryData[lowerCode]
321
+ // MUST be uppercased before `.of()`. V8 does not canonicalise the region
322
+ // subtag: `.of('us')` returns the string `'us'` while `.of('US')` returns
323
+ // "United States". `countryData` stores alpha-2 lowercase (it feeds the
324
+ // `i-circle-flags-*` name), so passing it straight through made every
325
+ // mapped country render as its own lowercase code.
326
+ const alpha2 = (data?.alpha2 || (code.length === 2 ? code : '')).toUpperCase() || null
327
+ if (alpha2) {
328
+ // `.of()` echoes the input back when it can't resolve the region.
329
+ const localized = getRegionDisplay()?.of(alpha2)
330
+ if (localized && localized.toUpperCase() !== alpha2)
331
+ return localized
332
+ }
333
+ return data?.name || fallback || code.toUpperCase()
334
+ }
335
+
336
+ // Shorter aliases used at call sites.
337
+ export { countryCodeToFlagIcon as countryFlag, getCountryName as countryName }