@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,681 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { usePreferredReducedMotion } from '@vueuse/core'
|
|
3
|
+
import { motion } from 'motion-v'
|
|
4
|
+
import { computed, useId } from 'vue'
|
|
5
|
+
import { vizColorMap } from '../../composables/dataVizColors'
|
|
6
|
+
import { semanticColors } from '../../composables/semanticColors'
|
|
7
|
+
|
|
8
|
+
type Datum = Record<string, number | string | null | undefined>
|
|
9
|
+
type Observation = number | null
|
|
10
|
+
type Series = Array<number | null> | Array<Datum | null>
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
data: Series
|
|
14
|
+
/** Previous period data, when provided, gradient reflects per-point changes */
|
|
15
|
+
previousData?: Series
|
|
16
|
+
xAxis?: string
|
|
17
|
+
yAxis?: string
|
|
18
|
+
width?: number | string
|
|
19
|
+
height?: number | string
|
|
20
|
+
size?: 'sm' | 'md' | 'lg'
|
|
21
|
+
/** Single color or named color (blue, green, purple, orange, red, neutral) */
|
|
22
|
+
color?: string
|
|
23
|
+
/** Full gradient color array, overrides color & trend defaults */
|
|
24
|
+
colors?: string[]
|
|
25
|
+
/** Colors for comparison mode [improved, declined, neutral] */
|
|
26
|
+
comparisonColors?: [string, string, string]
|
|
27
|
+
strokeWidth?: number
|
|
28
|
+
/** Drawing budget for observed points. Each separate run keeps its endpoints. */
|
|
29
|
+
maxPoints?: number
|
|
30
|
+
/** SVG preserveAspectRatio, set to "none" to stretch fill */
|
|
31
|
+
preserveAspectRatio?: string
|
|
32
|
+
/** Render style. 'line' (smooth curve), 'step' (stepped line for slow integer-valued metrics like DA). Discrete counts and bucketed runs use `UiSparkColumns`. */
|
|
33
|
+
variant?: 'line' | 'step'
|
|
34
|
+
/**
|
|
35
|
+
* Lower-is-better metric (position, CLS, bounce rate).
|
|
36
|
+
*
|
|
37
|
+
* Flips the y-axis so the SMALLEST value sits at the top: a rank of #1 draws
|
|
38
|
+
* high, a slide to #30 draws down. Same convention as `ProGraphGsc`'s position
|
|
39
|
+
* scale, the app's other rank chart — the two used to disagree, and a reader
|
|
40
|
+
* moving between them had to re-learn which direction was bad.
|
|
41
|
+
*
|
|
42
|
+
* With the axis flipped, "line rises" and "metric improved" mean the same
|
|
43
|
+
* thing for every metric, inverted or not. `trend` is negated to match, so it
|
|
44
|
+
* keeps reading as valence (+1 = better) AND as the drawn direction.
|
|
45
|
+
*/
|
|
46
|
+
inverted?: boolean
|
|
47
|
+
/** Render the area fill under the line. Defaults to false (Tufte-correct: pure line). Opt in for ambient/background ribbons. */
|
|
48
|
+
area?: boolean
|
|
49
|
+
/** How to render `previousData`. `ghost` (default) draws a thin neutral line behind the current series. `diverging` recolors the current line green/red per-point against the previous value (noisier; opt in when valence-per-point matters). */
|
|
50
|
+
comparisonStyle?: 'ghost' | 'diverging'
|
|
51
|
+
/** Enable the hover tracer — a brighter line that draws along the path. Driven by `hovered`. */
|
|
52
|
+
interactive?: boolean
|
|
53
|
+
/** Hover state, fed from a parent (e.g. the enclosing UiStat card). */
|
|
54
|
+
hovered?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Row id for an enclosing `UiTooltipGrid`. When set with `interactive`, every
|
|
57
|
+
* observed point gets an invisible pointer band carrying `data-tooltip-row`,
|
|
58
|
+
* `data-tooltip-col` (the point's index in `data`) and `data-tooltip-value`.
|
|
59
|
+
*/
|
|
60
|
+
tooltipRow?: number | string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const {
|
|
64
|
+
data,
|
|
65
|
+
previousData,
|
|
66
|
+
yAxis,
|
|
67
|
+
width,
|
|
68
|
+
height,
|
|
69
|
+
size = 'md',
|
|
70
|
+
color,
|
|
71
|
+
colors,
|
|
72
|
+
comparisonColors = [semanticColors.success.hex, semanticColors.error.hex, semanticColors.neutral.hex],
|
|
73
|
+
strokeWidth = 1.5,
|
|
74
|
+
maxPoints = 80,
|
|
75
|
+
preserveAspectRatio,
|
|
76
|
+
variant = 'line',
|
|
77
|
+
inverted = false,
|
|
78
|
+
area = false,
|
|
79
|
+
comparisonStyle = 'ghost',
|
|
80
|
+
interactive = false,
|
|
81
|
+
hovered = false,
|
|
82
|
+
tooltipRow,
|
|
83
|
+
} = defineProps<Props>()
|
|
84
|
+
|
|
85
|
+
// Gate the looping hover tracer dot for reduced-motion users (the CSS tracer
|
|
86
|
+
// line is already gated in <style>).
|
|
87
|
+
const reducedMotionPref = usePreferredReducedMotion()
|
|
88
|
+
const reducedMotion = computed(() => reducedMotionPref.value === 'reduce')
|
|
89
|
+
|
|
90
|
+
// Named tokens (e.g. `color="blue"`, `color="clicks"`) resolve through the
|
|
91
|
+
// canonical viz palette so the sparkline shares the same blue as the legend
|
|
92
|
+
// dot, the metric pill, and the chart series. Raw hex / CSS color strings pass
|
|
93
|
+
// through untouched for one-off callers.
|
|
94
|
+
function resolveColor(c: string): string {
|
|
95
|
+
return vizColorMap[c]?.hex ?? c
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sizeDefaults: Record<string, { w: number, h: number }> = {
|
|
99
|
+
sm: { w: 96, h: 24 },
|
|
100
|
+
md: { w: 120, h: 32 },
|
|
101
|
+
lg: { w: 160, h: 36 },
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Size fallback when `size` isn't one of the named presets. `md` is the
|
|
105
|
+
// documented default; using `!` on the indexed lookup here keeps the
|
|
106
|
+
// compiler from flagging every downstream read as possibly-undefined.
|
|
107
|
+
const defaults = computed(() => sizeDefaults[size] ?? sizeDefaults.md!)
|
|
108
|
+
|
|
109
|
+
// CSS width/height — can be string like "100%" or number
|
|
110
|
+
const cssWidth = computed(() => width ?? defaults.value.w)
|
|
111
|
+
const cssHeight = computed(() => height ?? defaults.value.h)
|
|
112
|
+
|
|
113
|
+
// Internal numeric dimensions for viewBox/path calculations
|
|
114
|
+
const vbW = computed(() => typeof cssWidth.value === 'number' ? cssWidth.value : defaults.value.w)
|
|
115
|
+
const vbH = computed(() => typeof cssHeight.value === 'number' ? cssHeight.value : defaults.value.h)
|
|
116
|
+
|
|
117
|
+
function observed(value: unknown): Observation {
|
|
118
|
+
if (value == null || value === '')
|
|
119
|
+
return null
|
|
120
|
+
const number = Number(value)
|
|
121
|
+
return Number.isFinite(number) ? number : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalize(input: Series | undefined, yAxis?: string): Observation[] {
|
|
125
|
+
if (!input?.length)
|
|
126
|
+
return []
|
|
127
|
+
const first = input.find(value => value != null)
|
|
128
|
+
if (first == null)
|
|
129
|
+
return input.map(() => null)
|
|
130
|
+
if (typeof first === 'number')
|
|
131
|
+
return input.map(observed)
|
|
132
|
+
const key = yAxis || guessYKey(first)
|
|
133
|
+
return (input as Array<Datum | null>).map(datum => observed(datum?.[key]))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function guessYKey(d: Datum): string {
|
|
137
|
+
for (const key of ['y', 'value', 'clicks', 'impressions', 'count']) {
|
|
138
|
+
if (key in d)
|
|
139
|
+
return key
|
|
140
|
+
}
|
|
141
|
+
const skip = new Set(['x', 'index', 'timestamp', 'date'])
|
|
142
|
+
for (const key of Object.keys(d)) {
|
|
143
|
+
if (!skip.has(key) && typeof d[key] === 'number')
|
|
144
|
+
return key
|
|
145
|
+
}
|
|
146
|
+
return Object.keys(d)[1] ?? Object.keys(d)[0] ?? 'value'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Largest Triangle Three Buckets — downsamples while preserving visual shape.
|
|
151
|
+
* Keeps first and last points, selects most visually significant point per bucket.
|
|
152
|
+
*/
|
|
153
|
+
function lttbIndices(vals: number[], target: number): number[] {
|
|
154
|
+
if (vals.length <= target)
|
|
155
|
+
return vals.map((_, index) => index)
|
|
156
|
+
if (target === 2)
|
|
157
|
+
return [0, vals.length - 1]
|
|
158
|
+
|
|
159
|
+
const result: number[] = [0]
|
|
160
|
+
const bucketSize = (vals.length - 2) / (target - 2)
|
|
161
|
+
|
|
162
|
+
let prevIndex = 0
|
|
163
|
+
for (let i = 1; i < target - 1; i++) {
|
|
164
|
+
const bucketStart = Math.floor((i - 1) * bucketSize) + 1
|
|
165
|
+
const bucketEnd = Math.min(Math.floor(i * bucketSize) + 1, vals.length - 1)
|
|
166
|
+
|
|
167
|
+
// Average of next bucket (for triangle area calc)
|
|
168
|
+
const nextStart = Math.floor(i * bucketSize) + 1
|
|
169
|
+
const nextEnd = Math.min(Math.floor((i + 1) * bucketSize) + 1, vals.length - 1)
|
|
170
|
+
let avgX = 0
|
|
171
|
+
let avgY = 0
|
|
172
|
+
const nextLen = nextEnd - nextStart + 1
|
|
173
|
+
for (let j = nextStart; j <= nextEnd; j++) {
|
|
174
|
+
avgX += j
|
|
175
|
+
avgY += vals[j]!
|
|
176
|
+
}
|
|
177
|
+
avgX /= nextLen
|
|
178
|
+
avgY /= nextLen
|
|
179
|
+
|
|
180
|
+
// Pick point in current bucket with largest triangle area
|
|
181
|
+
let maxArea = -1
|
|
182
|
+
let bestIndex = bucketStart
|
|
183
|
+
const px = prevIndex
|
|
184
|
+
const py = vals[prevIndex]!
|
|
185
|
+
for (let j = bucketStart; j <= bucketEnd; j++) {
|
|
186
|
+
const area = Math.abs((px - avgX) * (vals[j]! - py) - (px - j) * (avgY - py))
|
|
187
|
+
if (area > maxArea) {
|
|
188
|
+
maxArea = area
|
|
189
|
+
bestIndex = j
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
result.push(bestIndex)
|
|
194
|
+
prevIndex = bestIndex
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
result.push(vals.length - 1)
|
|
198
|
+
return result
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Each contiguous observed run is sampled separately. Missing days remain
|
|
202
|
+
// gaps, while original indices retain their place in the full reporting window.
|
|
203
|
+
function sampleRuns(observations: Observation[], limit: number): number[][] {
|
|
204
|
+
const runs: number[][] = []
|
|
205
|
+
const observedCount = observations.reduce<number>((count, value) => count + Number(value != null), 0)
|
|
206
|
+
let start = 0
|
|
207
|
+
while (start < observations.length) {
|
|
208
|
+
if (observations[start] == null) {
|
|
209
|
+
start++
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
let end = start + 1
|
|
213
|
+
while (end < observations.length && observations[end] != null) end++
|
|
214
|
+
const values = observations.slice(start, end) as number[]
|
|
215
|
+
const budget = Math.max(2, Math.round(limit * values.length / observedCount))
|
|
216
|
+
runs.push(lttbIndices(values, budget).map(index => start + index))
|
|
217
|
+
start = end
|
|
218
|
+
}
|
|
219
|
+
return runs
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const fullValues = computed(() => normalize(data, yAxis))
|
|
223
|
+
const drawingLimit = computed(() => Number.isFinite(maxPoints) ? Math.max(2, Math.floor(maxPoints)) : 80)
|
|
224
|
+
const sampledRuns = computed(() => sampleRuns(fullValues.value, drawingLimit.value))
|
|
225
|
+
const sampledIndices = computed(() => sampledRuns.value.flat())
|
|
226
|
+
const values = computed(() => sampledIndices.value.map(index => fullValues.value[index] as number))
|
|
227
|
+
const fullPreviousValues = computed(() => normalize(previousData, yAxis))
|
|
228
|
+
const previousRuns = computed(() => sampleRuns(fullPreviousValues.value, drawingLimit.value))
|
|
229
|
+
const prevValues = computed(() => previousRuns.value.flat().map(index => fullPreviousValues.value[index] as number))
|
|
230
|
+
const hasComparison = computed(() => prevValues.value.length > 0)
|
|
231
|
+
// Halves split the full reporting window by time, never the drawn sample.
|
|
232
|
+
// Sampling keeps peaks and drops flat stretches, so a mean over the sample
|
|
233
|
+
// reads one spike as the level of a whole half and can flip the direction.
|
|
234
|
+
const observedHalves = computed(() => {
|
|
235
|
+
const observations = fullValues.value
|
|
236
|
+
const mid = Math.floor(observations.length / 2)
|
|
237
|
+
const mean = (observations: Observation[]) => {
|
|
238
|
+
const values = observations.filter((value): value is number => value != null)
|
|
239
|
+
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null
|
|
240
|
+
}
|
|
241
|
+
return { early: mean(observations.slice(0, mid)), late: mean(observations.slice(mid)) }
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// Trend from half-window MEANS, not endpoints: daily series are seasonal (a
|
|
245
|
+
// weekend last point read as "downward" against a Wednesday first point) and
|
|
246
|
+
// endpoint comparison also amplifies reporting-lag artifacts. A <2% relative
|
|
247
|
+
// difference reads as flat.
|
|
248
|
+
const trend = computed<-1 | 0 | 1>(() => {
|
|
249
|
+
const { early, late } = observedHalves.value
|
|
250
|
+
if (early == null || late == null)
|
|
251
|
+
return 0
|
|
252
|
+
const base = Math.max(Math.abs(early), Math.abs(late))
|
|
253
|
+
const dir = base === 0 || Math.abs(late - early) / base < 0.02 ? 0 : late > early ? 1 : -1
|
|
254
|
+
// Not a double-negation with the flipped axis — the two cancel by design.
|
|
255
|
+
// `dir` is computed from the raw VALUES, where "up" means a bigger number; on
|
|
256
|
+
// an inverted metric a bigger number is worse AND is drawn lower. Negating
|
|
257
|
+
// here makes `trend` agree with both: +1 = better = the line goes up.
|
|
258
|
+
return inverted ? (-dir as -1 | 0 | 1) : dir
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// Charts convey data visually only, so the sr-label carries direction AND the
|
|
262
|
+
// early/recent averages (matching how `trend` is computed) rather than the
|
|
263
|
+
// seasonal-noise-prone endpoints.
|
|
264
|
+
//
|
|
265
|
+
// An inverted metric gets valence words, not line words. "Downward trend,
|
|
266
|
+
// averaging 9 early to 16 recently" is a contradiction read aloud — the numbers
|
|
267
|
+
// plainly went up. "Worsening" is what actually happened to the rank, which is
|
|
268
|
+
// the fact the reader wants; the shape of the line is the sighted proxy for it.
|
|
269
|
+
const ariaLabel = computed(() => {
|
|
270
|
+
const { early, late } = observedHalves.value
|
|
271
|
+
if (early == null || late == null)
|
|
272
|
+
return 'Sparkline, trend unavailable'
|
|
273
|
+
const dir = inverted
|
|
274
|
+
? (trend.value === 1 ? 'improving' : trend.value === -1 ? 'worsening' : 'flat')
|
|
275
|
+
: (trend.value === 1 ? 'upward' : trend.value === -1 ? 'downward' : 'flat')
|
|
276
|
+
const fmt = (n: number) => Math.abs(n) >= 10 || Number.isInteger(n) ? String(Math.round(n)) : n.toFixed(2)
|
|
277
|
+
return `Sparkline, ${dir} trend, averaging ${fmt(early)} early to ${fmt(late)} recently`
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Per-point comparison colors: at each data point, compare current vs previous
|
|
281
|
+
const comparisonGradientColors = computed<string[]>(() => {
|
|
282
|
+
const curr = values.value
|
|
283
|
+
const prev = fullPreviousValues.value
|
|
284
|
+
if (!prev.length)
|
|
285
|
+
return []
|
|
286
|
+
|
|
287
|
+
const [up, down, flat] = comparisonColors
|
|
288
|
+
// Compare original time positions. Sampling chooses different days for each
|
|
289
|
+
// history, so sampled array ordinals cannot identify matching observations.
|
|
290
|
+
return curr.map((v, i) => {
|
|
291
|
+
const originalIndex = sampledIndices.value[i]!
|
|
292
|
+
const fraction = fullValues.value.length > 1 ? originalIndex / (fullValues.value.length - 1) : 0
|
|
293
|
+
const previousIndex = Math.round(fraction * (prev.length - 1))
|
|
294
|
+
const previous = prev[previousIndex]
|
|
295
|
+
if (previous == null)
|
|
296
|
+
return flat
|
|
297
|
+
const difference = (v - previous) * (inverted ? -1 : 1)
|
|
298
|
+
return difference > 0 ? up : difference < 0 ? down : flat
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const gradientColors = computed(() => {
|
|
303
|
+
if (hasComparison.value && comparisonStyle === 'diverging' && comparisonGradientColors.value.length >= 2)
|
|
304
|
+
return comparisonGradientColors.value
|
|
305
|
+
|
|
306
|
+
const seed = semanticColors.neutral.hex
|
|
307
|
+
if (colors)
|
|
308
|
+
return colors.map(resolveColor)
|
|
309
|
+
if (color)
|
|
310
|
+
return [seed, resolveColor(color)]
|
|
311
|
+
return [seed, seed]
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// Ghost line projection of previousData — drawn behind the current line in a
|
|
315
|
+
// muted color so the reader sees both periods without per-point color churn.
|
|
316
|
+
// Shares `sharedYScale` with the current line for honest comparison.
|
|
317
|
+
const prevPathD = computed(() => {
|
|
318
|
+
if (!hasComparison.value || comparisonStyle !== 'ghost')
|
|
319
|
+
return ''
|
|
320
|
+
return buildSeriesPath(fullPreviousValues.value, previousRuns.value)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
// Build gradient stops for SVG linearGradient (evenly spaced)
|
|
324
|
+
const gradientStops = computed(() => {
|
|
325
|
+
const colors = gradientColors.value
|
|
326
|
+
if (colors.length <= 1)
|
|
327
|
+
return [{ offset: '0%', color: colors[0] || 'var(--ui-text-dimmed)' }, { offset: '100%', color: colors[0] || 'var(--ui-text-dimmed)' }]
|
|
328
|
+
const diverging = hasComparison.value && comparisonStyle === 'diverging' && comparisonGradientColors.value.length >= 2
|
|
329
|
+
return colors.map((color, i) => ({
|
|
330
|
+
offset: `${(diverging ? sampledIndices.value[i]! / (fullValues.value.length - 1) : i / (colors.length - 1)) * 100}%`,
|
|
331
|
+
color,
|
|
332
|
+
}))
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// Single-pass min/max — avoids `Math.min(...arr)` spread (which allocates an
|
|
336
|
+
// args array and risks a call-stack overflow on large series).
|
|
337
|
+
function minMax(arr: number[]): { min: number, max: number } {
|
|
338
|
+
let min = Infinity
|
|
339
|
+
let max = -Infinity
|
|
340
|
+
for (const v of arr) {
|
|
341
|
+
if (v < min)
|
|
342
|
+
min = v
|
|
343
|
+
if (v > max)
|
|
344
|
+
max = v
|
|
345
|
+
}
|
|
346
|
+
return { min, max }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Smallest peak-to-trough spread, as a fraction of the series' own magnitude,
|
|
351
|
+
* that is allowed to fill the chart height.
|
|
352
|
+
*
|
|
353
|
+
* The y-scale auto-fits, so without a floor ANY spread is stretched to the full
|
|
354
|
+
* height: a metric sitting at 100 that jitters by 0.05 draws the same violent
|
|
355
|
+
* zigzag as one that halved. `trend` and the aria-label both correctly said
|
|
356
|
+
* "flat" while the picture screamed movement — the one question a sparkline
|
|
357
|
+
* exists to answer, answered wrong.
|
|
358
|
+
*
|
|
359
|
+
* 8% is picked to sit between the two numbers this component already cares
|
|
360
|
+
* about. Below it is `trend`'s 2% relative-difference flat cut, so a series
|
|
361
|
+
* wobbling inside a couple of percent of its own magnitude now draws as a
|
|
362
|
+
* near-flat line instead of noise. Above it, comfortably, is the smallest move
|
|
363
|
+
* a reader would call real: a CTR going 2.1% → 2.9% is a 28% peak-to-trough
|
|
364
|
+
* spread, 3.5x the floor, so it is untouched and still fills the chart.
|
|
365
|
+
*
|
|
366
|
+
* A floor rather than a flat-series short-circuit, deliberately. A threshold
|
|
367
|
+
* that swaps the curve for a straight line makes a series hovering at the cut
|
|
368
|
+
* flip between "dead flat" and "full height" between refreshes, which is a
|
|
369
|
+
* worse lie than the one being fixed. Scaling by a floored range degrades
|
|
370
|
+
* continuously: amplitude drawn = (relative spread / 0.08) of the chart, so
|
|
371
|
+
* 2% draws at a quarter height, 0.05% draws at sub-pixel, and nothing jumps.
|
|
372
|
+
*/
|
|
373
|
+
const FLAT_RANGE_FLOOR = 0.08
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Widen `bounds` to the floor when the series is flatter than FLAT_RANGE_FLOOR,
|
|
377
|
+
* keeping the data centred in the widened band so a truly constant series draws
|
|
378
|
+
* through the middle rather than pinned to the chart's bottom edge.
|
|
379
|
+
*
|
|
380
|
+
* A series at or above the floor is returned byte-identically (same object),
|
|
381
|
+
* so every non-flat sparkline in the product projects to the exact same path.
|
|
382
|
+
*/
|
|
383
|
+
function floorRange(bounds: { min: number, max: number }): { min: number, max: number } {
|
|
384
|
+
const spread = bounds.max - bounds.min
|
|
385
|
+
// Magnitude, not spread, sets the floor: "flat" is relative to how big the
|
|
386
|
+
// numbers are. A 0.05 wobble is noise at 100 and a doubling at 0.05.
|
|
387
|
+
const magnitude = Math.max(Math.abs(bounds.max), Math.abs(bounds.min))
|
|
388
|
+
const minRange = magnitude * FLAT_RANGE_FLOOR
|
|
389
|
+
if (!(spread < minRange))
|
|
390
|
+
return bounds
|
|
391
|
+
const mid = (bounds.max + bounds.min) / 2
|
|
392
|
+
return { min: mid - minRange / 2, max: mid + minRange / 2 }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function projectPoints(vals: number[], indices: number[], originalLength: number, yScale?: { min: number, max: number }) {
|
|
396
|
+
const padX = strokeWidth
|
|
397
|
+
const padY = strokeWidth
|
|
398
|
+
const chartW = vbW.value - padX * 2
|
|
399
|
+
const chartH = vbH.value - padY * 2
|
|
400
|
+
|
|
401
|
+
const bounds = floorRange(yScale ?? minMax(vals))
|
|
402
|
+
const min = bounds.min
|
|
403
|
+
const max = bounds.max
|
|
404
|
+
const range = max - min || 1
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
padX,
|
|
408
|
+
padY,
|
|
409
|
+
chartW,
|
|
410
|
+
chartH,
|
|
411
|
+
// The ONE place the y-axis direction is decided, so nothing downstream
|
|
412
|
+
// (line, step, bars, area close, tracer) can disagree about which way is up.
|
|
413
|
+
// Normally the largest value is highest; `inverted` puts the SMALLEST value
|
|
414
|
+
// there instead, because on a lower-is-better metric that is the good end.
|
|
415
|
+
points: vals.map((v, i) => {
|
|
416
|
+
const t = (v - min) / range
|
|
417
|
+
return {
|
|
418
|
+
x: padX + (indices[i]! / (originalLength - 1)) * chartW,
|
|
419
|
+
y: inverted ? padY + t * chartH : padY + chartH - t * chartH,
|
|
420
|
+
}
|
|
421
|
+
}),
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Shared y-scale when rendering current + previous as overlay (ghost mode).
|
|
426
|
+
const sharedYScale = computed<{ min: number, max: number } | undefined>(() => {
|
|
427
|
+
const all = hasComparison.value && comparisonStyle === 'ghost'
|
|
428
|
+
? [...values.value, ...prevValues.value]
|
|
429
|
+
: values.value
|
|
430
|
+
return all.length ? minMax(all) : undefined
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
function buildPath(vals: number[], indices: number[], originalLength: number): string {
|
|
434
|
+
if (!vals.length)
|
|
435
|
+
return ''
|
|
436
|
+
|
|
437
|
+
const { padY, chartH, points } = projectPoints(vals, indices, originalLength, sharedYScale.value)
|
|
438
|
+
// Catmull-Rom control points overshoot local extremes: at an asymmetric peak
|
|
439
|
+
// (small step in, steep drop out) the control point lands above the chart top
|
|
440
|
+
// and the SVG viewport clips the curve flat — the graph's max/min gets "cut
|
|
441
|
+
// off". Clamping control-point Y to the chart area keeps the whole cubic
|
|
442
|
+
// inside it (Bézier convex-hull property) and also stops the curve implying
|
|
443
|
+
// values beyond the data's actual range.
|
|
444
|
+
const clampY = (y: number) => Math.min(padY + chartH, Math.max(padY, y))
|
|
445
|
+
|
|
446
|
+
if (points.length === 1)
|
|
447
|
+
return `M ${points[0]!.x} ${points[0]!.y} L ${points[0]!.x} ${points[0]!.y}`
|
|
448
|
+
const segments: string[] = [`M ${points[0]!.x} ${points[0]!.y}`]
|
|
449
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
450
|
+
const p0 = points[Math.max(i - 1, 0)]!
|
|
451
|
+
const p1 = points[i]!
|
|
452
|
+
const p2 = points[i + 1]!
|
|
453
|
+
const p3 = points[Math.min(i + 2, points.length - 1)]!
|
|
454
|
+
|
|
455
|
+
const tension = 0.3
|
|
456
|
+
// Sampling leaves uneven time gaps. Keep controls within this interval
|
|
457
|
+
// so a neighboring long gap cannot curl the line backwards in time.
|
|
458
|
+
const clampX = (x: number) => Math.min(p2.x, Math.max(p1.x, x))
|
|
459
|
+
const cp1x = clampX(p1.x + (p2.x - p0.x) * tension)
|
|
460
|
+
const cp1y = clampY(p1.y + (p2.y - p0.y) * tension)
|
|
461
|
+
const cp2x = clampX(p2.x - (p3.x - p1.x) * tension)
|
|
462
|
+
const cp2y = clampY(p2.y - (p3.y - p1.y) * tension)
|
|
463
|
+
|
|
464
|
+
segments.push(`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return segments.join(' ')
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function buildStepPath(vals: number[], indices: number[], originalLength: number): string {
|
|
471
|
+
if (!vals.length)
|
|
472
|
+
return ''
|
|
473
|
+
const { points } = projectPoints(vals, indices, originalLength, sharedYScale.value)
|
|
474
|
+
if (points.length === 1)
|
|
475
|
+
return `M ${points[0]!.x} ${points[0]!.y} L ${points[0]!.x} ${points[0]!.y}`
|
|
476
|
+
const segments: string[] = [`M ${points[0]!.x} ${points[0]!.y}`]
|
|
477
|
+
for (let i = 1; i < points.length; i++) {
|
|
478
|
+
const p = points[i]!
|
|
479
|
+
const prev = points[i - 1]!
|
|
480
|
+
segments.push(`L ${p.x} ${prev.y}`, `L ${p.x} ${p.y}`)
|
|
481
|
+
}
|
|
482
|
+
return segments.join(' ')
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const strokeGradientId = useId()
|
|
486
|
+
const areaGradientId = `${strokeGradientId}-area`
|
|
487
|
+
|
|
488
|
+
function buildSeriesPath(observations: Observation[], runs: number[][]): string {
|
|
489
|
+
if (observations.length < 2)
|
|
490
|
+
return ''
|
|
491
|
+
return runs.map((indices) => {
|
|
492
|
+
const values = indices.map(index => observations[index] as number)
|
|
493
|
+
return variant === 'step'
|
|
494
|
+
? buildStepPath(values, indices, observations.length)
|
|
495
|
+
: buildPath(values, indices, observations.length)
|
|
496
|
+
}).join(' ')
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const pathD = computed(() => buildSeriesPath(fullValues.value, sampledRuns.value))
|
|
500
|
+
|
|
501
|
+
const areaPathD = computed(() => {
|
|
502
|
+
// `inverted` used to be excluded here: with the y-axis unflipped the fill ran
|
|
503
|
+
// from a rising "bad" line down to the floor and read as a ceiling. Now that
|
|
504
|
+
// `projectPoints` flips the axis, the line sits above the floor the same way
|
|
505
|
+
// it does for any other metric and the fill is an ordinary under-curve area,
|
|
506
|
+
// so the carve-out is gone. A caller passing `area` gets an area.
|
|
507
|
+
if (!pathD.value || !area)
|
|
508
|
+
return ''
|
|
509
|
+
const closeY = vbH.value - strokeWidth
|
|
510
|
+
return sampledRuns.value.map((indices) => {
|
|
511
|
+
const values = indices.map(index => fullValues.value[index] as number)
|
|
512
|
+
const runPath = variant === 'step'
|
|
513
|
+
? buildStepPath(values, indices, fullValues.value.length)
|
|
514
|
+
: buildPath(values, indices, fullValues.value.length)
|
|
515
|
+
const { points } = projectPoints(values, indices, fullValues.value.length)
|
|
516
|
+
return `${runPath} L ${points.at(-1)!.x} ${closeY} L ${points[0]!.x} ${closeY} Z`
|
|
517
|
+
}).join(' ')
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
const endColor = computed(() => gradientColors.value.at(-1) || 'var(--ui-text-dimmed)')
|
|
521
|
+
const areaY1 = computed(() => trend.value === -1 ? '1' : '0')
|
|
522
|
+
const areaY2 = computed(() => trend.value === -1 ? '0' : '1')
|
|
523
|
+
|
|
524
|
+
// Pointer-only hit targets for UiTooltipGrid: one invisible band per observed
|
|
525
|
+
// point, split at the midpoints between neighbours. They stay out of the tab
|
|
526
|
+
// order because the svg already names the series through its role and label.
|
|
527
|
+
const hitTargets = computed(() => {
|
|
528
|
+
if (!interactive || tooltipRow == null || fullValues.value.length < 2)
|
|
529
|
+
return []
|
|
530
|
+
const length = fullValues.value.length
|
|
531
|
+
const xAt = (index: number) => strokeWidth + index / (length - 1) * (vbW.value - strokeWidth * 2)
|
|
532
|
+
return sampledRuns.value.flatMap((indices) => {
|
|
533
|
+
const runValues = indices.map(index => fullValues.value[index] as number)
|
|
534
|
+
const { points } = projectPoints(runValues, indices, length)
|
|
535
|
+
return points.map((point, i) => {
|
|
536
|
+
const index = indices[i]!
|
|
537
|
+
const left = i > 0 ? (points[i - 1]!.x + point.x) / 2 : index === 0 ? 0 : xAt(index - 0.5)
|
|
538
|
+
const right = i < points.length - 1 ? (point.x + points[i + 1]!.x) / 2 : index === length - 1 ? vbW.value : xAt(index + 0.5)
|
|
539
|
+
return { x: left, width: right - left, value: runValues[i]!, col: index }
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
</script>
|
|
544
|
+
|
|
545
|
+
<template>
|
|
546
|
+
<!-- overflow-visible: with non-scaling-stroke, a sparkline rendered smaller
|
|
547
|
+
than its viewBox shrinks the padding below the (constant, screen-px)
|
|
548
|
+
stroke half-width; the tracer dot (r=2.2 > padY) also pokes past the
|
|
549
|
+
edge at extremes. Sub-pixel bleed beats clipped peaks. -->
|
|
550
|
+
<svg
|
|
551
|
+
v-if="pathD"
|
|
552
|
+
data-ui="UiSparkline"
|
|
553
|
+
class="overflow-visible"
|
|
554
|
+
:width="cssWidth"
|
|
555
|
+
:height="cssHeight"
|
|
556
|
+
:viewBox="`0 0 ${vbW} ${vbH}`"
|
|
557
|
+
:preserveAspectRatio="preserveAspectRatio"
|
|
558
|
+
role="img"
|
|
559
|
+
:aria-label="ariaLabel"
|
|
560
|
+
>
|
|
561
|
+
<defs>
|
|
562
|
+
<!-- A horizontal path has no bounding-box height. Use the line's SVG
|
|
563
|
+
coordinates so its gradient still paints a constant history. -->
|
|
564
|
+
<linearGradient
|
|
565
|
+
:id="strokeGradientId"
|
|
566
|
+
gradientUnits="userSpaceOnUse"
|
|
567
|
+
:x1="strokeWidth"
|
|
568
|
+
y1="0"
|
|
569
|
+
:x2="vbW - strokeWidth"
|
|
570
|
+
y2="0"
|
|
571
|
+
>
|
|
572
|
+
<stop
|
|
573
|
+
v-for="stop in gradientStops"
|
|
574
|
+
:key="stop.offset"
|
|
575
|
+
:offset="stop.offset"
|
|
576
|
+
:style="{ stopColor: stop.color }"
|
|
577
|
+
/>
|
|
578
|
+
</linearGradient>
|
|
579
|
+
<linearGradient :id="areaGradientId" x1="0" :y1="areaY1" x2="0" :y2="areaY2">
|
|
580
|
+
<stop offset="0%" :style="{ stopColor: endColor, stopOpacity: 0.25 }" />
|
|
581
|
+
<stop offset="100%" :style="{ stopColor: endColor, stopOpacity: 0 }" />
|
|
582
|
+
</linearGradient>
|
|
583
|
+
</defs>
|
|
584
|
+
<path :d="areaPathD" :fill="`url(#${areaGradientId})`" stroke="none" />
|
|
585
|
+
<path
|
|
586
|
+
v-if="prevPathD"
|
|
587
|
+
:d="prevPathD"
|
|
588
|
+
:stroke="semanticColors.neutral.hex"
|
|
589
|
+
:stroke-width="Math.max(0.75, strokeWidth - 0.5)"
|
|
590
|
+
stroke-linecap="round"
|
|
591
|
+
stroke-linejoin="round"
|
|
592
|
+
stroke-opacity="0.35"
|
|
593
|
+
fill="none"
|
|
594
|
+
vector-effect="non-scaling-stroke"
|
|
595
|
+
/>
|
|
596
|
+
<path
|
|
597
|
+
:d="pathD"
|
|
598
|
+
:stroke="`url(#${strokeGradientId})`"
|
|
599
|
+
:stroke-width="strokeWidth"
|
|
600
|
+
stroke-linecap="round"
|
|
601
|
+
stroke-linejoin="round"
|
|
602
|
+
fill="none"
|
|
603
|
+
vector-effect="non-scaling-stroke"
|
|
604
|
+
/>
|
|
605
|
+
<!-- Hover tracer: a brighter copy of the line that draws itself along the
|
|
606
|
+
path on hover (pathLength=1 normalises dasharray regardless of scale). -->
|
|
607
|
+
<path
|
|
608
|
+
v-if="interactive && pathD"
|
|
609
|
+
class="ui-sparkline-tracer"
|
|
610
|
+
:class="{ 'is-hovered': hovered }"
|
|
611
|
+
:d="pathD"
|
|
612
|
+
:stroke="endColor"
|
|
613
|
+
:stroke-width="strokeWidth + 0.5"
|
|
614
|
+
stroke-linecap="round"
|
|
615
|
+
stroke-linejoin="round"
|
|
616
|
+
fill="none"
|
|
617
|
+
pathLength="1"
|
|
618
|
+
vector-effect="non-scaling-stroke"
|
|
619
|
+
/>
|
|
620
|
+
<!-- Hover tracer dot: rides the exact smoothed path via offset-path so it
|
|
621
|
+
glides continuously along the curve (not stepping between points).
|
|
622
|
+
tabindex -1: motion-v binds focus listeners, and Chromium makes any
|
|
623
|
+
SVG element with one a Tab stop. The dot carries no name or action. -->
|
|
624
|
+
<motion.circle
|
|
625
|
+
v-if="interactive && pathD"
|
|
626
|
+
tabindex="-1"
|
|
627
|
+
aria-hidden="true"
|
|
628
|
+
:r="2.2"
|
|
629
|
+
cx="0"
|
|
630
|
+
cy="0"
|
|
631
|
+
:fill="endColor"
|
|
632
|
+
:style="{ offsetPath: `path('${pathD}')`, offsetRotate: '0deg', filter: 'drop-shadow(0 0 2px var(--ui-bg))' }"
|
|
633
|
+
:initial="{ opacity: 0, offsetDistance: '0%' }"
|
|
634
|
+
:animate="!hovered
|
|
635
|
+
? { opacity: 0 }
|
|
636
|
+
: reducedMotion
|
|
637
|
+
? { offsetDistance: '100%', opacity: 0.9 }
|
|
638
|
+
: { offsetDistance: ['0%', '100%'], opacity: [0, 1, 1, 0] }"
|
|
639
|
+
:transition="!hovered
|
|
640
|
+
? { duration: 0.25 }
|
|
641
|
+
: reducedMotion
|
|
642
|
+
? { duration: 0 }
|
|
643
|
+
: { duration: 2.2, ease: 'easeInOut', repeat: Infinity }"
|
|
644
|
+
/>
|
|
645
|
+
<rect
|
|
646
|
+
v-for="target in hitTargets"
|
|
647
|
+
:key="`hit-${target.col}`"
|
|
648
|
+
:x="target.x"
|
|
649
|
+
y="0"
|
|
650
|
+
:width="target.width"
|
|
651
|
+
:height="vbH"
|
|
652
|
+
fill="transparent"
|
|
653
|
+
:data-tooltip-row="tooltipRow"
|
|
654
|
+
:data-tooltip-col="target.col"
|
|
655
|
+
:data-tooltip-value="target.value"
|
|
656
|
+
/>
|
|
657
|
+
</svg>
|
|
658
|
+
</template>
|
|
659
|
+
|
|
660
|
+
<style scoped>
|
|
661
|
+
/* Tracer rests fully retracted (offset = full length) and draws to 0 on hover,
|
|
662
|
+
so a brighter line sweeps left→right along the data. */
|
|
663
|
+
.ui-sparkline-tracer {
|
|
664
|
+
stroke-dasharray: 1;
|
|
665
|
+
stroke-dashoffset: 1;
|
|
666
|
+
opacity: 0;
|
|
667
|
+
transition:
|
|
668
|
+
stroke-dashoffset 700ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
669
|
+
opacity 200ms ease-out;
|
|
670
|
+
}
|
|
671
|
+
.ui-sparkline-tracer.is-hovered {
|
|
672
|
+
stroke-dashoffset: 0;
|
|
673
|
+
opacity: 0.9;
|
|
674
|
+
}
|
|
675
|
+
@media (prefers-reduced-motion: reduce) {
|
|
676
|
+
.ui-sparkline-tracer {
|
|
677
|
+
transition: opacity 150ms ease-out;
|
|
678
|
+
stroke-dashoffset: 0;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
</style>
|