Modrinth/components/ui/charts/ChartDisplay.vue
Prospector 1d9fe0c03d
Add new links card and feature flag system for incremental dev. (#1714)
* Add new links card and feature flag system for incremental dev.

* Switch to env variable for dev flags

* Add members card

* fix order of creators card

* Fix owner icon color and bring org owner to top of list

* lint + other fixes

* Revamp feature flag system, add flag config page

* Add button to flags page in dev mode

* fix env overrides

* make typescript happy with the refs
2024-06-11 19:46:07 -07:00

742 lines
22 KiB
Vue

<template>
<div>
<div v-if="analytics.error.value" class="universal-card">
<h2>
<span class="label__title">Error</span>
</h2>
<div>
{{ analytics.error.value }}
</div>
</div>
<div v-else class="graphs">
<div class="graphs__vertical-bar">
<client-only>
<CompactChart
v-if="analytics.formattedData.value.downloads"
ref="tinyDownloadChart"
:title="`Downloads since ${dayjs(startDate).format('MMM D, YYYY')}`"
color="var(--color-brand)"
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
:data="analytics.formattedData.value.downloads.chart.sumData"
:labels="analytics.formattedData.value.downloads.chart.labels"
suffix="<svg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='2'><path stroke-linecap='round' stroke-linejoin='round' d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' /></svg>"
:class="`clickable chart-button-base button-base ${
selectedChart === 'downloads'
? 'chart-button-base__selected button-base__selected'
: ''
}`"
:onclick="() => (selectedChart = 'downloads')"
role="button"
/>
</client-only>
<client-only>
<CompactChart
v-if="analytics.formattedData.value.views"
ref="tinyViewChart"
:title="`Page views since ${dayjs(startDate).format('MMM D, YYYY')}`"
color="var(--color-blue)"
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
:data="analytics.formattedData.value.views.chart.sumData"
:labels="analytics.formattedData.value.views.chart.labels"
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/><circle cx='12' cy='12' r='3'/></svg>"
:class="`clickable chart-button-base button-base ${
selectedChart === 'views' ? 'chart-button-base__selected button-base__selected' : ''
}`"
:onclick="() => (selectedChart = 'views')"
role="button"
/>
</client-only>
<client-only>
<CompactChart
v-if="analytics.formattedData.value.revenue"
ref="tinyRevenueChart"
:title="`Revenue since ${dayjs(startDate).format('MMM D, YYYY')}`"
color="var(--color-purple)"
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
:data="analytics.formattedData.value.revenue.chart.sumData"
:labels="analytics.formattedData.value.revenue.chart.labels"
is-money
:class="`clickable chart-button-base button-base ${
selectedChart === 'revenue' ? 'chart-button-base__selected button-base__selected' : ''
}`"
:onclick="() => (selectedChart = 'revenue')"
role="button"
/>
</client-only>
</div>
<div class="graphs__main-graph">
<div class="universal-card">
<div class="chart-controls">
<h2>
<span class="label__title">
{{ formatCategoryHeader(selectedChart) }}
</span>
</h2>
<div class="chart-controls__buttons">
<Button v-tooltip="'Toggle project colors'" icon-only @click="onToggleColors">
<PaletteIcon />
</Button>
<Button v-tooltip="'Download this data as CSV'" icon-only @click="onDownloadSetAsCSV">
<DownloadIcon />
</Button>
<Button v-tooltip="'Refresh the chart'" icon-only @click="resetCharts">
<UpdatedIcon />
</Button>
<DropdownSelect
v-model="selectedRange"
:options="selectableRanges"
name="Time range"
:display-name="(o: typeof selectableRanges[number] | undefined) => o?.label || 'Custom'"
/>
</div>
</div>
<div class="chart-area">
<div class="chart">
<client-only>
<Chart
v-if="analytics.formattedData.value.downloads && selectedChart === 'downloads'"
ref="downloadsChart"
type="line"
name="Download data"
:hide-legend="true"
:data="analytics.formattedData.value.downloads.chart.data"
:labels="analytics.formattedData.value.downloads.chart.labels"
suffix="<svg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='2'><path stroke-linecap='round' stroke-linejoin='round' d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' /></svg>"
:colors="
isUsingProjectColors
? analytics.formattedData.value.downloads.chart.colors
: analytics.formattedData.value.downloads.chart.defaultColors
"
/>
<Chart
v-if="analytics.formattedData.value.views && selectedChart === 'views'"
ref="viewsChart"
type="line"
name="View data"
:hide-legend="true"
:data="analytics.formattedData.value.views.chart.data"
:labels="analytics.formattedData.value.views.chart.labels"
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/><circle cx='12' cy='12' r='3'/></svg>"
:colors="
isUsingProjectColors
? analytics.formattedData.value.views.chart.colors
: analytics.formattedData.value.views.chart.defaultColors
"
/>
<Chart
v-if="analytics.formattedData.value.revenue && selectedChart === 'revenue'"
ref="revenueChart"
type="line"
name="Revenue data"
:hide-legend="true"
:data="analytics.formattedData.value.revenue.chart.data"
:labels="analytics.formattedData.value.revenue.chart.labels"
is-money
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><line x1='12' y1='2' x2='12' y2='22'></line><path d='M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6'></path></svg>"
:colors="
isUsingProjectColors
? analytics.formattedData.value.revenue.chart.colors
: analytics.formattedData.value.revenue.chart.defaultColors
"
/>
</client-only>
</div>
<div class="legend">
<div class="legend__items">
<template v-for="project in selectedDataSetProjects" :key="project">
<button
v-tooltip="project.title"
:class="`legend__item button-base btn-transparent ${
!projectIsOnDisplay(project.id) ? 'btn-dimmed' : ''
}`"
@click="
() =>
projectIsOnDisplay(project.id) &&
analytics.validProjectIds.value.includes(project.id)
? removeProjectFromDisplay(project.id)
: addProjectToDisplay(project.id)
"
>
<div
:style="{
'--color-brand': isUsingProjectColors
? intToRgba(project.color, project.id, theme.value ?? undefined)
: getDefaultColor(project.id),
}"
class="legend__item__color"
></div>
<div class="legend__item__text">{{ project.title }}</div>
</button>
</template>
</div>
</div>
</div>
</div>
<div class="country-data">
<Card
v-if="
analytics.formattedData.value?.downloadsByCountry &&
selectedChart === 'downloads' &&
analytics.formattedData.value.downloadsByCountry.data.length > 0
"
class="country-downloads"
>
<label>
<span class="label__title">Downloads by region</span>
</label>
<div class="country-values">
<div
v-for="[name, count] in analytics.formattedData.value.downloadsByCountry.data"
:key="name"
class="country-value"
>
<div class="country-flag-container">
<img
:src="
name.toLowerCase() === 'xx' || !name
? 'https://cdn.modrinth.com/placeholder-banner.svg'
: countryCodeToFlag(name)
"
alt="Hidden country"
class="country-flag"
/>
</div>
<div class="country-text">
<strong class="country-name"
><template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
<template v-else>{{ countryCodeToName(name) }}</template>
</strong>
<span class="data-point">{{ formatNumber(count) }}</span>
</div>
<div
v-tooltip="
formatPercent(count, analytics.formattedData.value.downloadsByCountry.sum)
"
class="percentage-bar"
>
<span
:style="{
width: formatPercent(
count,
analytics.formattedData.value.downloadsByCountry.sum
),
backgroundColor: 'var(--color-brand)',
}"
></span>
</div>
</div>
</div>
</Card>
<Card
v-if="
analytics.formattedData.value?.viewsByCountry &&
selectedChart === 'views' &&
analytics.formattedData.value.viewsByCountry.data.length > 0
"
class="country-downloads"
>
<label>
<span class="label__title">Page views by region</span>
</label>
<div class="country-values">
<div
v-for="[name, count] in analytics.formattedData.value.viewsByCountry.data"
:key="name"
class="country-value"
>
<div class="country-flag-container">
<img
:src="
name.toLowerCase() === 'xx' || !name
? 'https://cdn.modrinth.com/placeholder-banner.svg'
: countryCodeToFlag(name)
"
alt="Hidden country"
class="country-flag"
/>
</div>
<div class="country-text">
<strong class="country-name">
<template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
<template v-else>{{ countryCodeToName(name) }}</template>
</strong>
<span class="data-point">{{ formatNumber(count) }}</span>
</div>
<div
v-tooltip="
`${
Math.round(
(count / analytics.formattedData.value.viewsByCountry.sum) * 10000
) / 100
}%`
"
class="percentage-bar"
>
<span
:style="{
width: `${(count / analytics.formattedData.value.viewsByCountry.sum) * 100}%`,
backgroundColor: 'var(--color-blue)',
}"
></span>
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Button,
UpdatedIcon,
DownloadIcon,
Card,
formatMoney,
formatNumber,
DropdownSelect,
formatCategoryHeader,
} from 'omorphia'
import dayjs from 'dayjs'
import { defineProps, ref, computed } from 'vue'
import { analyticsSetToCSVString, intToRgba } from '~/utils/analytics.js'
import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components'
import PaletteIcon from '~/assets/icons/palette.svg'
const router = useRouter()
const theme = useTheme()
const props = withDefaults(
defineProps<{
projects?: any[]
/**
* @deprecated Use `ranges` instead
*/
resoloutions?: Record<string, number>
ranges?: Record<number, [string, number] | string>
personal?: boolean
}>(),
{
projects: undefined,
resoloutions: () => defaultResoloutions,
ranges: () => defaultRanges,
personal: false,
}
)
const projects = ref(props.projects || [])
const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) => ({
label: typeof extra === 'string' ? extra : extra[0],
value: Number(duration),
res: typeof extra === 'string' ? Number(duration) : extra[1],
}))
// const selectedChart = ref('downloads')
const selectedChart = computed({
get: () => {
const id = (router.currentRoute.value.query?.chart as string | undefined) || 'downloads'
// if the id is anything but the 3 charts we have or undefined, throw an error
if (!['downloads', 'views', 'revenue'].includes(id)) {
throw new Error(`Unknown chart ${id}`)
}
return id
},
set: (chart) => {
router.push({
query: {
...router.currentRoute.value.query,
chart,
},
})
},
})
// Chart refs
const downloadsChart = ref()
const viewsChart = ref()
const revenueChart = ref()
const tinyDownloadChart = ref()
const tinyViewChart = ref()
const tinyRevenueChart = ref()
const selectedDisplayProjects = ref(props.projects || [])
const removeProjectFromDisplay = (id: string) => {
selectedDisplayProjects.value = selectedDisplayProjects.value.filter((p) => p.id !== id)
}
const addProjectToDisplay = (id: string) => {
selectedDisplayProjects.value = [
...selectedDisplayProjects.value,
props.projects?.find((p) => p.id === id),
].filter(Boolean)
}
const projectIsOnDisplay = (id: string) => {
return selectedDisplayProjects.value?.some((p) => p.id === id) ?? false
}
const resetCharts = () => {
downloadsChart.value?.resetChart()
viewsChart.value?.resetChart()
revenueChart.value?.resetChart()
tinyDownloadChart.value?.resetChart()
tinyViewChart.value?.resetChart()
tinyRevenueChart.value?.resetChart()
}
const isUsingProjectColors = computed({
get: () => {
return (
router.currentRoute.value.query?.colors === 'true' ||
router.currentRoute.value.query?.colors === undefined
)
},
set: (newValue) => {
router.push({
query: {
...router.currentRoute.value.query,
colors: newValue ? 'true' : 'false',
},
})
},
})
const analytics = useFetchAllAnalytics(
resetCharts,
projects,
selectedDisplayProjects,
props.personal
)
const { startDate, endDate, timeRange, timeResolution } = analytics
const selectedRange = computed({
get: () => {
return (
selectableRanges.find((option) => option.value === timeRange.value) || {
label: 'Custom',
value: timeRange.value,
}
)
},
set: (newRange: { label: string; value: number; res?: number }) => {
timeRange.value = newRange.value
startDate.value = Date.now() - timeRange.value * 60 * 1000
endDate.value = Date.now()
if (newRange?.res) {
timeResolution.value = newRange.res
}
},
})
const selectedDataSet = computed(() => {
switch (selectedChart.value) {
case 'downloads':
return analytics.totalData.value.downloads
case 'views':
return analytics.totalData.value.views
case 'revenue':
return analytics.totalData.value.revenue
default:
throw new Error(`Unknown chart ${selectedChart.value}`)
}
})
const selectedDataSetProjects = computed(() => {
return selectedDataSet.value.projectIds
.map((id) => props.projects?.find((p) => p?.id === id))
.filter(Boolean)
})
const downloadSelectedSetAsCSV = () => {
const selectedChartName = selectedChart.value
const csv = analyticsSetToCSVString(selectedDataSet.value)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `${selectedChartName}-data.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
}
const onDownloadSetAsCSV = useClientTry(async () => await downloadSelectedSetAsCSV())
const onToggleColors = () => {
isUsingProjectColors.value = !isUsingProjectColors.value
}
</script>
<script lang="ts">
const defaultResoloutions: Record<string, number> = {
'5 minutes': 5,
'30 minutes': 30,
'An hour': 60,
'12 hours': 720,
'A day': 1440,
'A week': 10080,
}
const defaultRanges: Record<number, [string, number] | string> = {
30: ['Last 30 minutes', 1],
60: ['Last hour', 5],
720: ['Last 12 hours', 15],
1440: ['Last day', 60],
10080: ['Last week', 720],
43200: ['Last month', 1440],
129600: ['Last quarter', 10080],
525600: ['Last year', 20160],
1051200: ['Last two years', 40320],
}
</script>
<style scoped lang="scss">
.chart-controls {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: var(--gap-md);
.chart-controls__buttons {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
* {
width: auto;
min-height: auto;
}
}
}
.chart-area {
display: flex;
flex-direction: row;
gap: var(--gap-md);
height: 100%;
.chart {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.legend {
margin-top: 24px;
overflow: hidden;
max-width: 26ch;
width: fit-content;
.legend__items {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
.legend__item {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
font-size: var(--font-size-sm);
width: 100%;
.legend__item__text {
white-space: nowrap;
text-overflow: ellipsis;
}
.legend__item__color {
height: var(--font-size-xs);
width: var(--font-size-xs);
border-radius: var(--radius-sm);
background-color: var(--color-brand);
flex-grow: 0;
flex-shrink: 0;
}
}
}
}
}
.btn-transparent {
background-color: transparent;
border: none;
cursor: pointer;
color: var(--text-color);
font-weight: var(--font-weight-regular);
}
.btn-dimmed {
opacity: 0.5;
}
.chart-button-base {
overflow: hidden;
}
.chart-button-base__selected {
color: var(--color-contrast);
background-color: var(--color-brand-highlight);
box-shadow: inset 0 0 0 transparent, 0 0 0 2px var(--color-brand);
&:hover {
background-color: var(--color-brand-highlight);
}
}
.graphs {
// Pages clip so we need to add a margin
margin-left: 0.25rem;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
.graphs__vertical-bar {
flex-grow: 0;
flex-shrink: 0;
gap: 0.75rem;
display: flex;
margin-right: 0.1rem;
}
}
.country-flag-container {
width: 40px;
height: 27px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
border: 1px solid var(--color-divider);
border-radius: var(--radius-xs);
}
.country-flag {
object-fit: cover;
min-width: 100%;
min-height: 100%;
}
.spark-data {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--gap-md);
}
.country-data {
display: grid;
grid-template-columns: 1fr;
gap: var(--gap-md);
}
.country-values {
display: flex;
flex-direction: column;
background-color: var(--color-bg);
border-radius: var(--radius-sm);
border: 1px solid var(--color-button-bg);
gap: var(--gap-md);
padding: var(--gap-md);
margin-top: var(--gap-md);
overflow-y: auto;
max-height: 24rem;
}
.country-value {
display: grid;
grid-template-areas: 'flag text bar';
grid-template-columns: auto 1fr 10rem;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--gap-sm);
.country-text {
grid-area: text;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
}
.percentage-bar {
grid-area: bar;
width: 100%;
height: 1rem;
background-color: var(--color-raised-bg);
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-sm);
overflow: hidden;
span {
display: block;
height: 100%;
}
}
}
@media (max-width: 768px) {
.chart-area {
flex-direction: column;
gap: var(--gap-md);
}
.chart-controls {
flex-direction: column;
gap: var(--gap-md);
}
.chart {
flex-direction: column;
gap: var(--gap-md);
}
.legend {
margin-top: 0px;
width: 100%;
max-width: 100%;
}
.graphs {
margin-left: 0px;
margin-top: 0px;
.graphs__vertical-bar {
flex-direction: column;
gap: 0;
margin-right: 0px;
}
}
.country-data {
display: block;
}
.country-value {
grid-template-columns: auto 1fr 5rem;
}
}
</style>