@gscdump/nuxt 0.19.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/LICENSE +21 -0
- package/README.md +138 -0
- package/app/assets/css/main.css +2 -0
- package/app/components/GscAnalyzerPanel.vue +94 -0
- package/app/components/GscAnonymizationBanner.vue +46 -0
- package/app/components/GscBootProgress.vue +297 -0
- package/app/components/GscCommandPalette.vue +77 -0
- package/app/components/GscDashboardPage.vue +26 -0
- package/app/components/GscEngineBadge.vue +28 -0
- package/app/components/GscHero.vue +215 -0
- package/app/components/GscLazyTopResult.vue +45 -0
- package/app/components/GscPerformanceChart.vue +532 -0
- package/app/components/GscPositionDistributionChart.vue +63 -0
- package/app/components/GscQueryLabel.vue +63 -0
- package/app/components/GscSitePageHeader.vue +40 -0
- package/app/components/GscSourceDebugPanel.vue +195 -0
- package/app/components/GscSyncStatusCell.vue +54 -0
- package/app/components/GscTopRollupCard.vue +90 -0
- package/app/composables/_useGscBackfill.ts +111 -0
- package/app/composables/_useGscQueryDispatcher.ts +122 -0
- package/app/composables/_useGscResource.ts +114 -0
- package/app/composables/_useGscSharedSiteResource.ts +136 -0
- package/app/composables/useGscAnalytics.ts +197 -0
- package/app/composables/useGscAnalyticsClient.ts +9 -0
- package/app/composables/useGscAnalyticsConfig.ts +8 -0
- package/app/composables/useGscAnalyticsSourceInfo.ts +143 -0
- package/app/composables/useGscAnalyzer.ts +374 -0
- package/app/composables/useGscAnalyzerBatch.ts +106 -0
- package/app/composables/useGscAnalyzerDefs.ts +118 -0
- package/app/composables/useGscAuth.ts +115 -0
- package/app/composables/useGscConsoleUrl.ts +45 -0
- package/app/composables/useGscCountries.ts +47 -0
- package/app/composables/useGscCurrentSite.ts +15 -0
- package/app/composables/useGscEngine.ts +16 -0
- package/app/composables/useGscInspectionHistory.ts +42 -0
- package/app/composables/useGscInspections.ts +52 -0
- package/app/composables/useGscPanelContext.ts +24 -0
- package/app/composables/useGscParquetTable.ts +199 -0
- package/app/composables/useGscPeriod.ts +243 -0
- package/app/composables/useGscQuery.ts +290 -0
- package/app/composables/useGscQueryDispatcher.ts +122 -0
- package/app/composables/useGscRequestIndexingInspect.ts +20 -0
- package/app/composables/useGscResource.ts +114 -0
- package/app/composables/useGscRollup.ts +252 -0
- package/app/composables/useGscRollupTable.ts +78 -0
- package/app/composables/useGscRowQuery.ts +56 -0
- package/app/composables/useGscSearchAppearance.ts +47 -0
- package/app/composables/useGscSitemapHistory.ts +41 -0
- package/app/composables/useGscSitemaps.ts +45 -0
- package/app/composables/useGscTableState.ts +119 -0
- package/app/plugins/analytics.ts +57 -0
- package/app/utils/anonymization.ts +24 -0
- package/app/utils/country-names.ts +56 -0
- package/app/utils/gsc-constants.ts +10 -0
- package/app/utils/gsc-error.ts +58 -0
- package/app/utils/gsc-fetch.ts +94 -0
- package/app/utils/gsc-filters.ts +32 -0
- package/app/utils/gsc-rows.ts +72 -0
- package/app/utils/position.ts +7 -0
- package/app/utils/setup-gsc-fetch-auth.ts +62 -0
- package/module.ts +81 -0
- package/nuxt.config.ts +36 -0
- package/package.json +75 -0
- package/types.ts +114 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
// 4-metric overlay chart for GSC daily data. Ported from nuxtseo-pro's
|
|
3
|
+
// ProGraphGsc. Each metric lives in its own VisXYContainer stacked in the
|
|
4
|
+
// same grid cell so each uses the full chart height — Unovis's single
|
|
5
|
+
// container doesn't support independent y-domains per series.
|
|
6
|
+
//
|
|
7
|
+
// Key features:
|
|
8
|
+
// - Optional previous-period comparison: prev rows are merged by calendar
|
|
9
|
+
// date (prev.date + 1 year = current.date) and exposed via dashed lines.
|
|
10
|
+
// - Calendar-aware tick planner: picks per-span tick count + format
|
|
11
|
+
// ("Mon 12" / "Jan 12" / "Jan"+year) so x-axis labels don't crowd.
|
|
12
|
+
// - Estimated-region overlay: dims the last GSC_STABLE_LATENCY_DAYS so
|
|
13
|
+
// viewers don't mistake GSC's 3-day finalization lag for a real dip.
|
|
14
|
+
// - Optional `fullValue` / `fullPrevValue` keep y-domains anchored to the
|
|
15
|
+
// full period when the user zooms — scale doesn't jump per view.
|
|
16
|
+
|
|
17
|
+
import { Scale, TextAlign } from '@unovis/ts'
|
|
18
|
+
import { VisArea, VisAxis, VisCrosshair, VisLine, VisTooltip, VisXYContainer } from '@unovis/vue'
|
|
19
|
+
import { GSC_STABLE_LATENCY_DAYS } from '../utils/gsc-constants'
|
|
20
|
+
|
|
21
|
+
interface DataRow {
|
|
22
|
+
date: string
|
|
23
|
+
clicks: number
|
|
24
|
+
impressions: number
|
|
25
|
+
position?: number
|
|
26
|
+
ctr?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
value,
|
|
31
|
+
prevValue = null,
|
|
32
|
+
fullValue,
|
|
33
|
+
fullPrevValue,
|
|
34
|
+
columns = ['clicks', 'impressions'],
|
|
35
|
+
height,
|
|
36
|
+
loading: loadingProp = false,
|
|
37
|
+
} = defineProps<{
|
|
38
|
+
value: DataRow[]
|
|
39
|
+
prevValue?: DataRow[] | null
|
|
40
|
+
/** Which metric layers to render. Default renders clicks + impressions. */
|
|
41
|
+
columns?: string[]
|
|
42
|
+
height?: number | string
|
|
43
|
+
loading?: boolean
|
|
44
|
+
/** Full (unzoomed) series — when provided, y-domains stay anchored to the full range. */
|
|
45
|
+
fullValue?: DataRow[]
|
|
46
|
+
fullPrevValue?: DataRow[] | null
|
|
47
|
+
}>()
|
|
48
|
+
|
|
49
|
+
const emit = defineEmits<{
|
|
50
|
+
tooltip: [data: DataRow | null, prev: DataRow | null, isEstimated: boolean]
|
|
51
|
+
}>()
|
|
52
|
+
|
|
53
|
+
const loading = computed(() => loadingProp)
|
|
54
|
+
|
|
55
|
+
const gscColors = {
|
|
56
|
+
clicks: { line: 'rgba(59, 130, 246, 0.9)', area: 'url(#gradient-clicks)', prev: 'rgba(59, 130, 246, 0.3)' },
|
|
57
|
+
impressions: { line: 'rgba(168, 85, 247, 0.7)', area: 'url(#gradient-impressions)', prev: 'rgba(168, 85, 247, 0.3)' },
|
|
58
|
+
ctr: { line: 'rgba(34, 197, 94, 0.9)', prev: 'rgba(34, 197, 94, 0.3)' },
|
|
59
|
+
position: { line: 'rgba(251, 146, 60, 0.9)', prev: 'rgba(251, 146, 60, 0.3)' },
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const margin = { left: 0, right: 0, top: 0, bottom: 36 }
|
|
63
|
+
const chartHeight = computed(() => Number(height) || 220)
|
|
64
|
+
const chartInnerHeight = computed(() => chartHeight.value - margin.top - margin.bottom)
|
|
65
|
+
|
|
66
|
+
const svgDefs = `
|
|
67
|
+
<linearGradient id="gradient-clicks" gradientTransform="rotate(90)">
|
|
68
|
+
<stop offset="0%" stop-color="rgba(59, 130, 246, 0.25)" />
|
|
69
|
+
<stop offset="100%" stop-color="rgba(59, 130, 246, 0.01)" />
|
|
70
|
+
</linearGradient>
|
|
71
|
+
<linearGradient id="gradient-impressions" gradientTransform="rotate(90)">
|
|
72
|
+
<stop offset="0%" stop-color="rgba(168, 85, 247, 0.2)" />
|
|
73
|
+
<stop offset="100%" stop-color="rgba(168, 85, 247, 0.01)" />
|
|
74
|
+
</linearGradient>`
|
|
75
|
+
|
|
76
|
+
type MergedRow = DataRow & { prev?: DataRow }
|
|
77
|
+
const mergedValue = computed<MergedRow[]>(() => {
|
|
78
|
+
if (!value.length)
|
|
79
|
+
return []
|
|
80
|
+
if (!prevValue?.length)
|
|
81
|
+
return value.slice()
|
|
82
|
+
const prevByDate = new Map<string, DataRow>()
|
|
83
|
+
for (const row of prevValue) prevByDate.set(row.date, row)
|
|
84
|
+
return value.map((row) => {
|
|
85
|
+
const [y, m, d] = row.date.split('-')
|
|
86
|
+
const key = `${Number(y) - 1}-${m}-${d}`
|
|
87
|
+
const match = prevByDate.get(key)
|
|
88
|
+
return match ? { ...row, prev: match } : row
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const hasComparison = computed(() => mergedValue.value.some(r => r.prev))
|
|
93
|
+
|
|
94
|
+
// NaN y-values break the cubic curve (d3-shape's default `defined` skips them),
|
|
95
|
+
// so the dashed line only renders across indices with matching prev data.
|
|
96
|
+
const prevClicks = (d: MergedRow) => d.prev?.clicks ?? Number.NaN
|
|
97
|
+
const prevImpressions = (d: MergedRow) => d.prev?.impressions ?? Number.NaN
|
|
98
|
+
const prevCtr = (d: MergedRow) => d.prev?.ctr != null ? d.prev.ctr * 100 : Number.NaN
|
|
99
|
+
const prevPosition = (d: MergedRow) => d.prev?.position ?? Number.NaN
|
|
100
|
+
|
|
101
|
+
interface TickPlan {
|
|
102
|
+
indices: number[]
|
|
103
|
+
format: (date: Date, i: number, firstYear: number) => string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const weekdayDayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric' })
|
|
107
|
+
const monthDayFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' })
|
|
108
|
+
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'short' })
|
|
109
|
+
const monthYearFmt = new Intl.DateTimeFormat(undefined, { month: 'short', year: '2-digit' })
|
|
110
|
+
|
|
111
|
+
const tickPlan = computed<TickPlan>(() => {
|
|
112
|
+
const len = value.length
|
|
113
|
+
if (len <= 1)
|
|
114
|
+
return { indices: [0], format: d => monthDayFmt.format(d) }
|
|
115
|
+
|
|
116
|
+
if (len <= 10)
|
|
117
|
+
return { indices: value.map((_, i) => i), format: d => weekdayDayFmt.format(d) }
|
|
118
|
+
|
|
119
|
+
if (len <= 35) {
|
|
120
|
+
const step = Math.max(1, Math.ceil(len / 5))
|
|
121
|
+
const indices: number[] = []
|
|
122
|
+
for (let i = 0; i < len; i += step) indices.push(i)
|
|
123
|
+
if (indices[indices.length - 1] !== len - 1)
|
|
124
|
+
indices.push(len - 1)
|
|
125
|
+
return { indices, format: d => monthDayFmt.format(d) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (len <= 120) {
|
|
129
|
+
const step = 14
|
|
130
|
+
const indices: number[] = []
|
|
131
|
+
for (let i = 0; i < len; i += step) indices.push(i)
|
|
132
|
+
return { indices, format: d => monthDayFmt.format(d) }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Long span: first-of-month ticks.
|
|
136
|
+
const indices: number[] = []
|
|
137
|
+
let lastMonth = ''
|
|
138
|
+
for (let i = 0; i < len; i++) {
|
|
139
|
+
const m = value[i]?.date.slice(0, 7)
|
|
140
|
+
if (!m)
|
|
141
|
+
continue
|
|
142
|
+
if (m !== lastMonth) {
|
|
143
|
+
indices.push(i)
|
|
144
|
+
lastMonth = m
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (indices.length < 2)
|
|
148
|
+
return { indices: [0, Math.floor(len / 2), len - 1], format: d => monthDayFmt.format(d) }
|
|
149
|
+
|
|
150
|
+
if (indices.length > 14) {
|
|
151
|
+
const keep = Math.ceil(indices.length / 12)
|
|
152
|
+
const thinned = indices.filter((_, i) => i % keep === 0)
|
|
153
|
+
return {
|
|
154
|
+
indices: thinned,
|
|
155
|
+
format: (d, i, firstYear) => (d.getMonth() === 0 || i === 0) && d.getFullYear() !== firstYear
|
|
156
|
+
? monthYearFmt.format(d)
|
|
157
|
+
: monthFmt.format(d),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
indices,
|
|
162
|
+
format: (d, i, _firstYear) => (d.getMonth() === 0 && i > 0) || (i === 0 && d.getMonth() !== 0 && indices.length > 6)
|
|
163
|
+
? monthYearFmt.format(d)
|
|
164
|
+
: monthFmt.format(d),
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const firstTickYear = computed(() => {
|
|
169
|
+
const first = tickPlan.value.indices[0]
|
|
170
|
+
const row = first == null ? undefined : value[first]
|
|
171
|
+
if (!row?.date)
|
|
172
|
+
return new Date().getFullYear()
|
|
173
|
+
return Number(row.date.slice(0, 4))
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
function tickFormat(d: number) {
|
|
177
|
+
const idx = Math.round(d)
|
|
178
|
+
if (idx < 0 || idx >= value.length)
|
|
179
|
+
return ''
|
|
180
|
+
const row = value[idx]
|
|
181
|
+
if (!row?.date)
|
|
182
|
+
return ''
|
|
183
|
+
const tickIdx = tickPlan.value.indices.indexOf(idx)
|
|
184
|
+
return tickPlan.value.format(new Date(row.date), tickIdx, firstTickYear.value)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Unstable region: compare YYYY-MM-DD strings directly — avoid Date round-trips
|
|
188
|
+
// that shift across timezones (AEST → UTC can drop the date back a day).
|
|
189
|
+
const unstableCutoffDate = computed(() => {
|
|
190
|
+
const pstStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' })
|
|
191
|
+
const parts = pstStr.split('-').map(Number)
|
|
192
|
+
const y = parts[0] ?? new Date().getFullYear()
|
|
193
|
+
const m = parts[1] ?? 1
|
|
194
|
+
const d = parts[2] ?? 1
|
|
195
|
+
const cutoff = new Date(y, m - 1, d - GSC_STABLE_LATENCY_DAYS)
|
|
196
|
+
return `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}-${String(cutoff.getDate()).padStart(2, '0')}`
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const unstableCount = computed(() => {
|
|
200
|
+
if (!value.length)
|
|
201
|
+
return 0
|
|
202
|
+
const cutoff = unstableCutoffDate.value
|
|
203
|
+
let count = 0
|
|
204
|
+
for (let i = value.length - 1; i >= 0; i--) {
|
|
205
|
+
const row = value[i]
|
|
206
|
+
if (row && row.date > cutoff)
|
|
207
|
+
count++
|
|
208
|
+
else break
|
|
209
|
+
}
|
|
210
|
+
return count
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const unstableWidthPct = computed(() => {
|
|
214
|
+
if (!unstableCount.value || value.length < 2)
|
|
215
|
+
return 0
|
|
216
|
+
return (unstableCount.value - 0.5) / (value.length - 1) * 100
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
function crosshairTemplate(d: DataRow) {
|
|
220
|
+
const idx = mergedValue.value.findIndex(r => r.date === d.date)
|
|
221
|
+
const prev = idx >= 0 ? (mergedValue.value[idx]?.prev ?? null) : null
|
|
222
|
+
const isEstimated = unstableCount.value > 0 && idx >= mergedValue.value.length - unstableCount.value
|
|
223
|
+
nextTick(() => emit('tooltip', d, prev, isEstimated))
|
|
224
|
+
return ''
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handleMouseLeave() {
|
|
228
|
+
emit('tooltip', null, null, false)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const allData = computed(() => {
|
|
232
|
+
const cur = fullValue ?? value
|
|
233
|
+
const prev = fullPrevValue !== undefined ? fullPrevValue : prevValue
|
|
234
|
+
return [...cur, ...(prev || [])]
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const clicksDomain = computed<[number, number]>(() => {
|
|
238
|
+
if (!allData.value.length)
|
|
239
|
+
return [0, 1]
|
|
240
|
+
const max = Math.max(...allData.value.map(d => d.clicks), 1)
|
|
241
|
+
return [0, max * 1.1]
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const impressionsDomain = computed<[number, number]>(() => {
|
|
245
|
+
if (!allData.value.length)
|
|
246
|
+
return [0, 1]
|
|
247
|
+
const max = Math.max(...allData.value.map(d => d.impressions), 1)
|
|
248
|
+
return [0, max * 1.1]
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const ctrScale = computed(() => {
|
|
252
|
+
const ctrs = allData.value.map(d => (d.ctr ?? 0) * 100).filter(c => c > 0)
|
|
253
|
+
const min = ctrs.length ? Math.min(...ctrs) : 0
|
|
254
|
+
const max = ctrs.length ? Math.max(...ctrs) : 10
|
|
255
|
+
const padding = (max - min) * 0.1 || 1
|
|
256
|
+
return Scale.scaleLinear()
|
|
257
|
+
.domain([Math.max(0, min - padding), max + padding])
|
|
258
|
+
.range([0, chartInnerHeight.value])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// Position scale inverted: lower GSC position (better rank) = higher on chart.
|
|
262
|
+
const positionScale = computed(() => {
|
|
263
|
+
const positions = allData.value.map(d => d.position ?? 0).filter(p => p > 0)
|
|
264
|
+
const min = positions.length ? Math.min(...positions) : 1
|
|
265
|
+
const max = positions.length ? Math.max(...positions) : 100
|
|
266
|
+
const padding = (max - min) * 0.1 || 1
|
|
267
|
+
return Scale.scaleLinear()
|
|
268
|
+
.domain([Math.max(1, min - padding), max + padding])
|
|
269
|
+
.range([chartInnerHeight.value, 0])
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// Force re-mount all containers when data shape changes — Unovis doesn't
|
|
273
|
+
// always rescale cleanly on same-length but different-range transitions.
|
|
274
|
+
const chartKey = computed(() => {
|
|
275
|
+
const len = value.length
|
|
276
|
+
const first = value[0]?.date ?? ''
|
|
277
|
+
const last = value[len - 1]?.date ?? ''
|
|
278
|
+
return `${len}-${first}-${last}`
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
const x = (_d: DataRow, i: number) => i
|
|
282
|
+
const clicks = (d: DataRow) => d.clicks
|
|
283
|
+
const impressions = (d: DataRow) => d.impressions
|
|
284
|
+
const ctr = (d: DataRow) => (d.ctr ?? 0) * 100
|
|
285
|
+
const position = (d: DataRow) => d.position ?? 0
|
|
286
|
+
</script>
|
|
287
|
+
|
|
288
|
+
<template>
|
|
289
|
+
<div
|
|
290
|
+
data-ui="GscPerformanceChart"
|
|
291
|
+
class="gsc-chart"
|
|
292
|
+
role="img"
|
|
293
|
+
aria-label="Search Console performance chart"
|
|
294
|
+
:style="{ height: `${chartHeight}px` }"
|
|
295
|
+
@mouseleave="handleMouseLeave"
|
|
296
|
+
>
|
|
297
|
+
<div v-if="loading" class="loading-skeleton">
|
|
298
|
+
<div class="flex-1 flex items-end gap-1">
|
|
299
|
+
<div v-for="i in 30" :key="i" class="flex-1 h-full flex">
|
|
300
|
+
<UiSkeleton type="bar" :index="i" />
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
<div class="flex justify-between mt-3">
|
|
304
|
+
<UiSkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<div v-else-if="value.length < 2" class="flex flex-col items-center justify-center h-full text-center gap-1">
|
|
309
|
+
<p class="text-sm text-muted">
|
|
310
|
+
{{ value.length === 0 ? 'No data for this period' : 'Only 1 day of data so far' }}
|
|
311
|
+
</p>
|
|
312
|
+
<p v-if="value.length === 1" class="text-xs text-dimmed">
|
|
313
|
+
The chart will appear once more days are available
|
|
314
|
+
</p>
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
<ClientOnly v-else>
|
|
318
|
+
<div
|
|
319
|
+
v-if="unstableWidthPct > 0"
|
|
320
|
+
class="estimated-region"
|
|
321
|
+
:style="{ width: `${unstableWidthPct}%`, bottom: `${margin.bottom}px` }"
|
|
322
|
+
>
|
|
323
|
+
<div class="estimated-tint" />
|
|
324
|
+
<div class="estimated-separator" />
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<VisXYContainer
|
|
328
|
+
v-if="columns.includes('impressions')"
|
|
329
|
+
:key="`impressions-${chartKey}`"
|
|
330
|
+
:height="chartHeight"
|
|
331
|
+
:data="mergedValue"
|
|
332
|
+
:svg-defs="svgDefs"
|
|
333
|
+
:margin="margin"
|
|
334
|
+
:auto-margin="false"
|
|
335
|
+
class="chart-layer"
|
|
336
|
+
:y-domain="impressionsDomain"
|
|
337
|
+
>
|
|
338
|
+
<VisLine
|
|
339
|
+
v-if="hasComparison"
|
|
340
|
+
curve-type="monotoneX"
|
|
341
|
+
:x="x"
|
|
342
|
+
:y="prevImpressions"
|
|
343
|
+
:color="gscColors.impressions.prev"
|
|
344
|
+
:line-width="1.5"
|
|
345
|
+
:line-dash-array="[6, 4]"
|
|
346
|
+
/>
|
|
347
|
+
<VisArea :color="gscColors.impressions.area" curve-type="monotoneX" :x="x" :y="impressions" />
|
|
348
|
+
<VisLine curve-type="monotoneX" :x="x" :y="impressions" :color="gscColors.impressions.line" :line-width="2" />
|
|
349
|
+
</VisXYContainer>
|
|
350
|
+
|
|
351
|
+
<VisXYContainer
|
|
352
|
+
v-if="columns.includes('clicks')"
|
|
353
|
+
:key="`clicks-${chartKey}`"
|
|
354
|
+
:height="chartHeight"
|
|
355
|
+
:data="mergedValue"
|
|
356
|
+
:svg-defs="svgDefs"
|
|
357
|
+
:margin="margin"
|
|
358
|
+
:auto-margin="false"
|
|
359
|
+
class="chart-layer"
|
|
360
|
+
:y-domain="clicksDomain"
|
|
361
|
+
>
|
|
362
|
+
<VisLine
|
|
363
|
+
v-if="hasComparison"
|
|
364
|
+
curve-type="monotoneX"
|
|
365
|
+
:x="x"
|
|
366
|
+
:y="prevClicks"
|
|
367
|
+
:color="gscColors.clicks.prev"
|
|
368
|
+
:line-width="1.5"
|
|
369
|
+
:line-dash-array="[6, 4]"
|
|
370
|
+
/>
|
|
371
|
+
<VisArea :color="gscColors.clicks.area" curve-type="monotoneX" :x="x" :y="clicks" />
|
|
372
|
+
<VisLine curve-type="monotoneX" :x="x" :y="clicks" :color="gscColors.clicks.line" :line-width="2" />
|
|
373
|
+
</VisXYContainer>
|
|
374
|
+
|
|
375
|
+
<VisXYContainer
|
|
376
|
+
v-if="columns.includes('ctr')"
|
|
377
|
+
:key="`ctr-${chartKey}`"
|
|
378
|
+
:height="chartHeight"
|
|
379
|
+
:data="mergedValue"
|
|
380
|
+
:margin="margin"
|
|
381
|
+
:auto-margin="false"
|
|
382
|
+
class="chart-layer"
|
|
383
|
+
>
|
|
384
|
+
<VisLine
|
|
385
|
+
v-if="hasComparison"
|
|
386
|
+
curve-type="monotoneX"
|
|
387
|
+
:x="x"
|
|
388
|
+
:y="prevCtr"
|
|
389
|
+
:y-scale="ctrScale"
|
|
390
|
+
:color="gscColors.ctr.prev"
|
|
391
|
+
:line-width="1.5"
|
|
392
|
+
:line-dash-array="[6, 4]"
|
|
393
|
+
/>
|
|
394
|
+
<VisLine curve-type="monotoneX" :x="x" :y="ctr" :y-scale="ctrScale" :color="gscColors.ctr.line" :line-width="2" />
|
|
395
|
+
</VisXYContainer>
|
|
396
|
+
|
|
397
|
+
<VisXYContainer
|
|
398
|
+
v-if="columns.includes('position')"
|
|
399
|
+
:key="`position-${chartKey}`"
|
|
400
|
+
:height="chartHeight"
|
|
401
|
+
:data="mergedValue"
|
|
402
|
+
:margin="margin"
|
|
403
|
+
:auto-margin="false"
|
|
404
|
+
class="chart-layer"
|
|
405
|
+
>
|
|
406
|
+
<VisLine
|
|
407
|
+
v-if="hasComparison"
|
|
408
|
+
curve-type="monotoneX"
|
|
409
|
+
:x="x"
|
|
410
|
+
:y="prevPosition"
|
|
411
|
+
:y-scale="positionScale"
|
|
412
|
+
:color="gscColors.position.prev"
|
|
413
|
+
:line-width="1.5"
|
|
414
|
+
:line-dash-array="[6, 4]"
|
|
415
|
+
/>
|
|
416
|
+
<VisLine curve-type="monotoneX" :x="x" :y="position" :y-scale="positionScale" :color="gscColors.position.line" :line-width="2" />
|
|
417
|
+
</VisXYContainer>
|
|
418
|
+
|
|
419
|
+
<VisXYContainer
|
|
420
|
+
:key="`interactive-${chartKey}`"
|
|
421
|
+
:height="chartHeight"
|
|
422
|
+
:data="mergedValue"
|
|
423
|
+
:margin="margin"
|
|
424
|
+
:auto-margin="false"
|
|
425
|
+
class="chart-layer chart-layer--interactive"
|
|
426
|
+
>
|
|
427
|
+
<VisLine :x="x" :y="clicks" color="transparent" :line-width="0" />
|
|
428
|
+
<VisAxis
|
|
429
|
+
type="x"
|
|
430
|
+
:tick-line="false"
|
|
431
|
+
:grid-line="false"
|
|
432
|
+
:domain-line="false"
|
|
433
|
+
:tick-values="tickPlan.indices"
|
|
434
|
+
:tick-format="tickFormat"
|
|
435
|
+
:tick-text-align="TextAlign.Left"
|
|
436
|
+
tick-text-font-size="11px"
|
|
437
|
+
tick-text-color="var(--ui-text-dimmed)"
|
|
438
|
+
/>
|
|
439
|
+
<VisTooltip :follow-cursor="false" horizontal-placement="right" />
|
|
440
|
+
<VisCrosshair color="none" :template="crosshairTemplate" />
|
|
441
|
+
</VisXYContainer>
|
|
442
|
+
|
|
443
|
+
<template #fallback>
|
|
444
|
+
<div class="loading-skeleton">
|
|
445
|
+
<div class="flex-1 flex items-end gap-1">
|
|
446
|
+
<div v-for="i in 30" :key="i" class="flex-1">
|
|
447
|
+
<UiSkeleton type="bar" :index="i" />
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
<div class="flex justify-between mt-3">
|
|
451
|
+
<UiSkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
</template>
|
|
455
|
+
</ClientOnly>
|
|
456
|
+
</div>
|
|
457
|
+
</template>
|
|
458
|
+
|
|
459
|
+
<style scoped>
|
|
460
|
+
[data-ui="GscPerformanceChart"] :deep(.unovis-area-group path) {
|
|
461
|
+
stroke: none;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.gsc-chart {
|
|
465
|
+
display: grid;
|
|
466
|
+
grid-template-columns: 1fr;
|
|
467
|
+
position: relative;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.gsc-chart .chart-layer,
|
|
471
|
+
.gsc-chart .loading-skeleton {
|
|
472
|
+
grid-column-start: 1;
|
|
473
|
+
grid-row-start: 1;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.gsc-chart .chart-layer {
|
|
477
|
+
pointer-events: none;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.gsc-chart .chart-layer--interactive {
|
|
481
|
+
pointer-events: auto;
|
|
482
|
+
--vis-crosshair-line-stroke-color: var(--ui-border-accented);
|
|
483
|
+
--vis-crosshair-line-stroke-opacity: 0.6;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.gsc-chart .chart-layer--interactive :deep(.unovis-tooltip) {
|
|
487
|
+
display: none;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.estimated-region {
|
|
491
|
+
position: absolute;
|
|
492
|
+
top: 0;
|
|
493
|
+
right: 0;
|
|
494
|
+
pointer-events: none;
|
|
495
|
+
z-index: 5;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.estimated-tint {
|
|
499
|
+
position: absolute;
|
|
500
|
+
inset: 0;
|
|
501
|
+
background: linear-gradient(
|
|
502
|
+
to right,
|
|
503
|
+
transparent,
|
|
504
|
+
color-mix(in srgb, var(--ui-bg) 60%, transparent) 40%
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.estimated-separator {
|
|
509
|
+
position: absolute;
|
|
510
|
+
left: 0;
|
|
511
|
+
top: 0;
|
|
512
|
+
bottom: 0;
|
|
513
|
+
width: 1px;
|
|
514
|
+
background: repeating-linear-gradient(
|
|
515
|
+
to bottom,
|
|
516
|
+
var(--ui-text-dimmed) 0,
|
|
517
|
+
var(--ui-text-dimmed) 3px,
|
|
518
|
+
transparent 3px,
|
|
519
|
+
transparent 7px
|
|
520
|
+
);
|
|
521
|
+
opacity: 0.35;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.loading-skeleton {
|
|
525
|
+
display: flex;
|
|
526
|
+
flex-direction: column;
|
|
527
|
+
justify-content: end;
|
|
528
|
+
padding-bottom: 2rem;
|
|
529
|
+
padding-left: 0.5rem;
|
|
530
|
+
padding-right: 0.5rem;
|
|
531
|
+
}
|
|
532
|
+
</style>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { CurveType } from '@unovis/ts'
|
|
3
|
+
import { VisAxis, VisCrosshair, VisLine, VisTooltip, VisXYContainer } from '@unovis/vue'
|
|
4
|
+
|
|
5
|
+
interface DataPoint {
|
|
6
|
+
date: string
|
|
7
|
+
pos_1_3: number
|
|
8
|
+
pos_4_10: number
|
|
9
|
+
pos_11_20: number
|
|
10
|
+
pos_20_plus: number
|
|
11
|
+
total: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
defineProps<{
|
|
15
|
+
data: DataPoint[]
|
|
16
|
+
height?: number
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const x = (_: DataPoint, i: number) => i
|
|
20
|
+
const pos1_3 = (d: DataPoint) => d.pos_1_3 ?? 0
|
|
21
|
+
const pos4_10 = (d: DataPoint) => d.pos_4_10 ?? 0
|
|
22
|
+
const pos11_20 = (d: DataPoint) => d.pos_11_20 ?? 0
|
|
23
|
+
const pos20plus = (d: DataPoint) => d.pos_20_plus ?? 0
|
|
24
|
+
|
|
25
|
+
const colors = {
|
|
26
|
+
top3: '#10b981',
|
|
27
|
+
page1: '#3b82f6',
|
|
28
|
+
page2: '#f59e0b',
|
|
29
|
+
deep: '#ef4444',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function template(d: DataPoint) {
|
|
33
|
+
return `
|
|
34
|
+
<div class="text-sm">
|
|
35
|
+
<div class="font-medium mb-1">${d.date}</div>
|
|
36
|
+
<div style="color: ${colors.top3}">Pos 1-3: ${d.pos_1_3?.toLocaleString() ?? 0}</div>
|
|
37
|
+
<div style="color: ${colors.page1}">Pos 4-10: ${d.pos_4_10?.toLocaleString() ?? 0}</div>
|
|
38
|
+
<div style="color: ${colors.page2}">Pos 11-20: ${d.pos_11_20?.toLocaleString() ?? 0}</div>
|
|
39
|
+
<div style="color: ${colors.deep}">Pos 20+: ${d.pos_20_plus?.toLocaleString() ?? 0}</div>
|
|
40
|
+
<div class="text-muted mt-1">Total: ${d.total?.toLocaleString() ?? 0}</div>
|
|
41
|
+
</div>
|
|
42
|
+
`
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<div :style="{ height: `${height ?? 240}px` }">
|
|
48
|
+
<VisXYContainer v-if="data?.length" :data="data" :height="height ?? 240">
|
|
49
|
+
<VisLine :x="x" :y="pos1_3" :color="colors.top3" :line-width="2" :curve-type="CurveType.MonotoneX" />
|
|
50
|
+
<VisLine :x="x" :y="pos4_10" :color="colors.page1" :line-width="2" :curve-type="CurveType.MonotoneX" />
|
|
51
|
+
<VisLine :x="x" :y="pos11_20" :color="colors.page2" :line-width="2" :curve-type="CurveType.MonotoneX" />
|
|
52
|
+
<VisLine :x="x" :y="pos20plus" :color="colors.deep" :line-width="2" :curve-type="CurveType.MonotoneX" />
|
|
53
|
+
|
|
54
|
+
<VisAxis type="x" :tick-format="(i: number) => data[i]?.date?.slice(5) ?? ''" :num-ticks="7" />
|
|
55
|
+
<VisAxis type="y" :tick-format="(n: number) => n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n)" />
|
|
56
|
+
<VisCrosshair :template="template" />
|
|
57
|
+
<VisTooltip />
|
|
58
|
+
</VisXYContainer>
|
|
59
|
+
<div v-else class="flex items-center justify-center h-full text-muted text-sm">
|
|
60
|
+
No data
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Keyword cell: keyword text + optional best-position chip + optional variant
|
|
3
|
+
// badge. Deliberately small — callers lay it out inside their own row/table.
|
|
4
|
+
|
|
5
|
+
const { keyword, position, variantCount = 0, href } = defineProps<{
|
|
6
|
+
keyword: string
|
|
7
|
+
position?: number
|
|
8
|
+
/** Number of GSC query-variants folded into this canonical keyword. */
|
|
9
|
+
variantCount?: number
|
|
10
|
+
href?: string
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const positionLabel = computed(() => {
|
|
14
|
+
if (position == null || !Number.isFinite(position) || position <= 0)
|
|
15
|
+
return null
|
|
16
|
+
return position.toFixed(1)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const positionColor = computed<'success' | 'warning' | 'neutral' | 'error'>(() => {
|
|
20
|
+
if (position == null)
|
|
21
|
+
return 'neutral'
|
|
22
|
+
if (position <= 3)
|
|
23
|
+
return 'success'
|
|
24
|
+
if (position <= 10)
|
|
25
|
+
return 'warning'
|
|
26
|
+
if (position <= 20)
|
|
27
|
+
return 'neutral'
|
|
28
|
+
return 'error'
|
|
29
|
+
})
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
34
|
+
<component
|
|
35
|
+
:is="href ? 'NuxtLink' : 'span'"
|
|
36
|
+
:to="href"
|
|
37
|
+
class="truncate text-default"
|
|
38
|
+
:class="href ? 'hover:text-primary hover:underline' : ''"
|
|
39
|
+
:title="keyword"
|
|
40
|
+
>
|
|
41
|
+
{{ keyword }}
|
|
42
|
+
</component>
|
|
43
|
+
<UBadge
|
|
44
|
+
v-if="positionLabel"
|
|
45
|
+
:color="positionColor"
|
|
46
|
+
variant="soft"
|
|
47
|
+
size="xs"
|
|
48
|
+
class="tabular-nums shrink-0"
|
|
49
|
+
>
|
|
50
|
+
#{{ positionLabel }}
|
|
51
|
+
</UBadge>
|
|
52
|
+
<UBadge
|
|
53
|
+
v-if="variantCount > 1"
|
|
54
|
+
color="neutral"
|
|
55
|
+
variant="soft"
|
|
56
|
+
size="xs"
|
|
57
|
+
class="tabular-nums shrink-0"
|
|
58
|
+
:title="`${variantCount} GSC query variants folded into this keyword`"
|
|
59
|
+
>
|
|
60
|
+
+{{ variantCount - 1 }}
|
|
61
|
+
</UBadge>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Site-scoped page header. Auto-prefixes the `[Overview, hostname-link]`
|
|
3
|
+
// crumb trail every `/sites/[id]/*` page repeats, on top of the layer's
|
|
4
|
+
// `PageHeader`. Hosts pass `:tail` for the remaining crumbs and use
|
|
5
|
+
// `<PageHeader>` directly for pages that don't fit the standard shape
|
|
6
|
+
// (e.g. the site index, where the title IS the hostname).
|
|
7
|
+
|
|
8
|
+
interface Crumb { label: string, to?: string }
|
|
9
|
+
|
|
10
|
+
const { tail = [], title, icon, description } = defineProps<{
|
|
11
|
+
tail?: Crumb[]
|
|
12
|
+
title: string
|
|
13
|
+
icon?: string
|
|
14
|
+
description?: string
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const { siteId, site } = useGscCurrentSite()
|
|
18
|
+
|
|
19
|
+
const crumbs = computed<Crumb[]>(() => [
|
|
20
|
+
{ label: 'Overview', to: '/' },
|
|
21
|
+
{ label: site.value?.hostname ?? siteId.value, to: `/sites/${encodeURIComponent(siteId.value)}` },
|
|
22
|
+
...tail,
|
|
23
|
+
])
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<PageHeader
|
|
28
|
+
:crumbs="crumbs"
|
|
29
|
+
:title="title"
|
|
30
|
+
:icon="icon"
|
|
31
|
+
:description="description"
|
|
32
|
+
>
|
|
33
|
+
<template v-if="$slots.icon" #icon>
|
|
34
|
+
<slot name="icon" />
|
|
35
|
+
</template>
|
|
36
|
+
<template #actions>
|
|
37
|
+
<slot name="actions" />
|
|
38
|
+
</template>
|
|
39
|
+
</PageHeader>
|
|
40
|
+
</template>
|