diff --git a/package-lock.json b/package-lock.json index 757f24f..2593abf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,17 +13,24 @@ "@mantine/nprogress": "^7.15.1", "@next/bundle-analyzer": "15.1.2", "@tabler/icons-react": "^3.26.0", + "@types/chroma-js": "^3.1.0", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", + "chart.js": "^4.4.7", + "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-datalabels": "^2.2.0", + "chroma-js": "^3.1.2", "clsx": "^2.1.1", "dayjs": "^1.11.13", "github-markdown-css": "^5.8.1", "hast-util-from-html": "^2.0.3", "highlight.js": "^11.11.0", + "i18n-iso-countries": "^7.13.0", "mermaid": "^11.4.1", "next": "15.1.2", "next-intl": "3.26.2", "react": "19.0.0", + "react-chartjs-2": "^5.3.0", "react-dom": "19.0.0", "react-markdown": "^9.0.1", "rehype-raw": "^7.0.0", @@ -567,6 +574,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@mantine/core": { "version": "7.15.1", "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.15.1.tgz", @@ -948,6 +961,12 @@ "react": ">= 16" } }, + "node_modules/@types/chroma-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-3.1.0.tgz", + "integrity": "sha512-Uwl3SOtUkbQ6Ye6ZYu4q4xdLGBzmY839sEHYtOT7i691neeyd+7fXWT5VIkcUSfNwIFrIjQutNYQn9h4q5HFvg==", + "license": "MIT" + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -2192,6 +2211,40 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chart.js": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.7.tgz", + "integrity": "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-adapter-dayjs-4": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz", + "integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "chart.js": ">=4.0.1", + "dayjs": "^1.9.7" + } + }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/chevrotain": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", @@ -2256,6 +2309,12 @@ "node": ">= 6" } }, + "node_modules/chroma-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz", + "integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -3079,6 +3138,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diacritics": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", + "integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4492,6 +4557,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/i18n-iso-countries": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.13.0.tgz", + "integrity": "sha512-pVh4CjdgAHZswI98hzG+1BItQlsQfR+yGDsjDISoWIV/jHDAvCmSyZ5vj2YWwAjfVZ8/BhBDqWcFvuGOyHe4vg==", + "license": "MIT", + "dependencies": { + "diacritics": "1.3.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7135,6 +7212,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", diff --git a/package.json b/package.json index 4d45f25..a88d56b 100644 --- a/package.json +++ b/package.json @@ -14,17 +14,24 @@ "@mantine/nprogress": "^7.15.1", "@next/bundle-analyzer": "15.1.2", "@tabler/icons-react": "^3.26.0", + "@types/chroma-js": "^3.1.0", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", + "chart.js": "^4.4.7", + "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-datalabels": "^2.2.0", + "chroma-js": "^3.1.2", "clsx": "^2.1.1", "dayjs": "^1.11.13", "github-markdown-css": "^5.8.1", "hast-util-from-html": "^2.0.3", "highlight.js": "^11.11.0", + "i18n-iso-countries": "^7.13.0", "mermaid": "^11.4.1", "next": "15.1.2", "next-intl": "3.26.2", "react": "19.0.0", + "react-chartjs-2": "^5.3.0", "react-dom": "19.0.0", "react-markdown": "^9.0.1", "rehype-raw": "^7.0.0", diff --git a/src/app/[locale]/stats/data.ts b/src/app/[locale]/stats/data.ts new file mode 100644 index 0000000..f5156df --- /dev/null +++ b/src/app/[locale]/stats/data.ts @@ -0,0 +1,13 @@ +interface DataList { + kind: string + key: string + subkey: string | null + timestamps: number[] + values: number[] +} + +interface GetDataResponse { + start: number + end: number + data: {[key: string]: DataList[]} +} diff --git a/src/app/[locale]/stats/page.tsx b/src/app/[locale]/stats/page.tsx new file mode 100644 index 0000000..cb41bcf --- /dev/null +++ b/src/app/[locale]/stats/page.tsx @@ -0,0 +1,118 @@ +import { LineChart } from "@/components/charts/line"; +import { Counts, NestedCounts, PieChart } from "@/components/charts/pie"; +import { CommonContentLayout } from "@/components/layout/common-content-layout"; +import { TimeFormatted } from "@/components/time-formatted"; +import { getTelemetryApiToken } from "@/utils/environment-utils"; +import { pick } from "@/utils/i18n-utils"; +import { getCountryCodeName } from "@/utils/iso-3166-utils"; +import { NextIntlClientProvider } from "next-intl"; +import { getLocale, getMessages, getTranslations } from "next-intl/server"; +import React from "react"; + +function getSecondsUntilNextHour(): number { + const now = new Date() + return 3600 - (now.getMinutes() * 60 + now.getSeconds()) +} + +async function mapCountryCode(counts: Counts): Promise { + const newCounts: Counts = {} + for (const [code, value] of Object.entries(counts)) { + const newCode = await getCountryCodeName(code) || code + newCounts[newCode] = value + } + return newCounts +} + +export default async function Page() { + const locale = await getLocale() + const messages = await getMessages() + const t = await getTranslations('page.stats') + + const url = 'https://telemetry.mcdreforged.com/api/data' + const fetchRsp = await fetch(url, { + headers: { + 'Authorization': `Bearer ${getTelemetryApiToken()}`, + }, + next: { + revalidate: Math.min(10 * 60, getSecondsUntilNextHour() + 30), + tags: ['telemetry'], + }, + }) + if (fetchRsp.status !== 200) { + console.error(`fetch telemetry data failed: ${fetchRsp.status} ${fetchRsp.statusText}`, fetchRsp) + return
Telemetry data fetching failed
+ } + const rsp = (await fetchRsp.json()) as GetDataResponse + + const mcdrInstancesTimstamps = rsp.data['mcdr_instance'][0].timestamps + const mcdrInstancesValues: {[label: string]: number[]} = {} + mcdrInstancesValues[t('line.total')] = rsp.data['mcdr_instance'][0].values + rsp.data['mcdr_version'].forEach(dataList => { + if (dataList.subkey) { + const points = new Map(dataList.timestamps.map((ts, index) => [ts, dataList.values[index]])) + mcdrInstancesValues[dataList.subkey] = mcdrInstancesTimstamps.map(ts => (points.get(ts) || 0)) + } + }) + + function extractCount(kind: string): NestedCounts { + let counts: NestedCounts = { + keyCounts: {}, + subkeyCounts: {}, + } + rsp.data[kind].forEach(dataList => { + const key = dataList.key + const subkey = dataList.subkey + if (!key) { + return + } + if (dataList.timestamps[dataList.timestamps.length - 1] !== rsp.end) { + return + } + const value = dataList.values[dataList.values.length - 1] + counts.keyCounts[key] = (counts.keyCounts[key] || 0) + value + if (subkey) { + if (!counts.subkeyCounts[key]) { + counts.subkeyCounts[key] = {} + } + counts.subkeyCounts[key][subkey] = value + } + }) + return counts + } + + // XXX: debug only + function HourLine({label, timestamp}: {label: string; timestamp: number}) { + return ( +

+ {label} + +

+ ) + } + + const countryCounts = extractCount('country') + countryCounts.keyCounts = await mapCountryCode(countryCounts.keyCounts) + + return ( + + +
+ + +

Hour count: {(rsp.end - rsp.start) / 3600 + 1}

+
+
+ +
+ + + + + + +
+
+
+
+ ) +} diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 5b8af9d..be589c3 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -1,7 +1,8 @@ +import { getRevalidateCatalogueToken } from "@/utils/environment-utils"; import { revalidatePath, revalidateTag } from "next/cache"; import { NextRequest } from "next/server"; -const revalidateToken = process.env.MW_REVALIDATE_CATALOGUE_TOKEN +const revalidateToken = getRevalidateCatalogueToken() export async function POST(request: NextRequest) { if (revalidateToken === undefined) { diff --git a/src/catalogue/data.ts b/src/catalogue/data.ts index 913a031..171d5e2 100644 --- a/src/catalogue/data.ts +++ b/src/catalogue/data.ts @@ -1,5 +1,6 @@ import { AllOfAPlugin, Everything } from "@/catalogue/meta-types"; import { SimpleEverything } from "@/catalogue/simple-types"; +import { getCatalogueEverythingUrl, shouldReadCatalogueEverythingFromLocalFile } from "@/utils/environment-utils"; import fs from 'fs/promises' import { notFound } from "next/navigation"; import { promisify } from "node:util"; @@ -17,7 +18,7 @@ async function fileExists(filePath: string) { } async function devReadLocalEverything(): Promise { - if (process.env.MW_USE_LOCAL_EVERYTHING === 'true') { + if (shouldReadCatalogueEverythingFromLocalFile()) { const localDataPath = path.join(process.cwd(), 'src', 'catalogue', 'everything.json') if (await fileExists(localDataPath)) { const content = await fs.readFile(localDataPath, 'utf8') @@ -30,7 +31,7 @@ async function devReadLocalEverything(): Promise { const gunzipAsync = promisify(gunzip) async function fetchEverything(): Promise { - const url: string = process.env.MW_EVERYTHING_JSON_URL || 'https://raw.githubusercontent.com/MCDReforged/PluginCatalogue/meta/everything.json.gz' + const url: string = getCatalogueEverythingUrl() // The 2nd init param cannot be defined as a standalone global constant variable, // or the ISR might be broken: fetchEverything() will never be invoked after the first 2 round of requests, diff --git a/src/components/charts/common.ts b/src/components/charts/common.ts new file mode 100644 index 0000000..c5c31dd --- /dev/null +++ b/src/components/charts/common.ts @@ -0,0 +1,75 @@ +'use client' + +import dayjs from "dayjs"; +import { useLocale } from "next-intl"; +import { useEffect } from "react"; + +export function useSetDayJsLocale() { + const locale = useLocale() + dayjs.locale(locale.toLowerCase()) + useEffect(() => { + dayjs.locale(locale.toLowerCase()) + return () => {dayjs.locale(undefined)} + }, [locale]) +} + +// color palette from grafana, licensed under AGPL-3.0 license +// https://github.com/grafana/grafana/blame/38c4f3d5ef8e37d7a0e0e3c493cde12f2bb30949/packages/grafana-data/src/themes/createVisualizationColors.ts#L225 +export const colorPalette = [ + '#7EB26D', + '#EAB839', + '#6ED0E0', + '#EF843C', + '#E24D42', + '#1F78C1', + '#BA43A9', + '#705DA0', + '#508642', + '#CCA300', + '#447EBC', + '#C15C17', + '#890F02', + '#0A437C', + '#6D1F62', + '#584477', + '#B7DBAB', + '#F4D598', + '#70DBED', + '#F9BA8F', + '#F29191', + '#82B5D8', + '#E5A8E2', + '#AEA2E0', + '#629E51', + '#E5AC0E', + '#64B0C8', + '#E0752D', + '#BF1B00', + '#0A50A1', + '#962D82', + '#614D93', + '#9AC48A', + '#F2C96D', + '#65C5DB', + '#F9934E', + '#EA6460', + '#5195CE', + '#D683CE', + '#806EB7', + '#3F6833', + '#967302', + '#2F575E', + '#99440A', + '#58140C', + '#052B51', + '#511749', + '#3F2B5B', + '#E0F9D7', + '#FCEACA', + '#CFFAFF', + '#F9E2D2', + '#FCE2DE', + '#BADFF4', + '#F9D9F9', + '#DEDAF7', +] diff --git a/src/components/charts/line.tsx b/src/components/charts/line.tsx new file mode 100644 index 0000000..0958e32 --- /dev/null +++ b/src/components/charts/line.tsx @@ -0,0 +1,111 @@ +'use client' + +import { arrayMax } from "@/utils/math-utils"; +import { CategoryScale, Chart, ChartDataset, Decimation, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip } from "chart.js"; +import chroma from "chroma-js"; +import { clsx } from "clsx"; +import { Chart as ReactChart } from "react-chartjs-2"; +import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; +import 'dayjs/locale/zh-cn' +import { colorPalette, useSetDayJsLocale } from "./common"; + +Chart.register(Title) +Chart.register(Tooltip) +Chart.register(Legend) +Chart.register(Decimation) + +Chart.register(CategoryScale) +Chart.register(LinearScale) +Chart.register(TimeScale) +Chart.register(LineElement) +Chart.register(PointElement) + +Chart.register(LineController) + +interface LineChartProps { + className?: string + title: string + + timestamps: number[] + values: {[label: string]: number[]} +} + +export function LineChart(props: LineChartProps) { + useSetDayJsLocale() + + let values = {...props.values} + if (Object.keys(values).length > 0) { + const maxCounts = Object.values(values).map(counts => arrayMax(counts)) + const maxMaxCount = arrayMax(maxCounts) + Object.keys(values).forEach((key, idx) => { + if (maxCounts[idx] / maxMaxCount < 0.01) { + delete values[key] + } + }) + } + + return ( + item * 1000), + datasets: Object.entries(values).map(([label, counts], idx) => { + return { + label: label, + data: counts, + tension: 0.5, + cubicInterpolationMode: 'monotone', + pointStyle: false, + backgroundColor: chroma(colorPalette[idx]).darken(0.3).hex(), + borderColor: chroma(colorPalette[idx]).brighten(0.1).hex(), + } as ChartDataset + }), + }} + /> + ) +} diff --git a/src/components/charts/pie.tsx b/src/components/charts/pie.tsx new file mode 100644 index 0000000..d1c32e1 --- /dev/null +++ b/src/components/charts/pie.tsx @@ -0,0 +1,249 @@ +'use client' + +import { arraySum } from "@/utils/math-utils"; +import { ArcElement, CategoryScale, Chart, ChartDataset, DoughnutController, Legend, LinearScale, LineElement, PointElement, TimeScale, Title, Tooltip } from "chart.js"; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import chroma from "chroma-js"; +import { clsx } from "clsx"; +import { useTranslations } from "next-intl"; +import { Chart as ReactChart } from "react-chartjs-2"; +import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; +import 'dayjs/locale/zh-cn' +import { colorPalette, useSetDayJsLocale } from "./common"; + +Chart.register(Title) +Chart.register(Legend) +Chart.register(Tooltip) + +Chart.register(CategoryScale) +Chart.register(LinearScale) +Chart.register(TimeScale) +Chart.register(ArcElement) +Chart.register(LineElement) +Chart.register(PointElement) + +Chart.register(DoughnutController) + + +const outerRingMinRatio = 0.03 +const innerRingMinRatio = 0.05 + +function aggregateTinyParts(counts: NestedCounts, otherLabel: string): NestedCounts { + const newCounts: NestedCounts = {keyCounts: {}, subkeyCounts: {}} + const valueSum = arraySum(Object.values(counts.keyCounts)) + const getRatio = (val: number) => val / valueSum + + { + let otherCountSum = 0 + let otherCountNum = 0 + Object.entries(counts.keyCounts).forEach(([key, count]) => { + if (getRatio(count) < outerRingMinRatio) { + otherCountSum += count + otherCountNum += 1 + } else { + newCounts.keyCounts[key] = count + newCounts.subkeyCounts[key] = {...(counts.subkeyCounts[key] || {})} + } + }) + if (otherCountNum <= 1) { + // no need to aggregate + newCounts.keyCounts = {...counts.keyCounts} + newCounts.subkeyCounts = {...counts.subkeyCounts} + } else { + newCounts.keyCounts[otherLabel] = otherCountSum + newCounts.subkeyCounts[otherLabel] = {otherLabel: otherCountSum} + } + } + + Object.entries(newCounts.subkeyCounts).forEach(([key, subCounts]) => { + const newSubCounts: Counts = {} + + let subOtherCountSum = 0 + let subOtherCountNum = 0 + Object.entries(subCounts).forEach(([subkey, subCount]) => { + if (getRatio(subCount) < innerRingMinRatio) { + subOtherCountSum += subCount + subOtherCountNum++ + } else { + newSubCounts[subkey] = subCount + } + }) + + if (subOtherCountNum >= 1) { + newSubCounts[otherLabel] = subOtherCountSum + newCounts.subkeyCounts[key] = newSubCounts + } + }) + + if (Object.keys(counts.subkeyCounts).length === 0) { + newCounts.subkeyCounts = {} + } + return newCounts +} + +export interface Counts { [label: string]: number } // dict of (label -> count) + +export interface NestedCounts { + keyCounts: Counts + subkeyCounts: {[label: string]: Counts} +} + +export interface PieChartProps { + className?: string + title: string + counts: NestedCounts +} + +interface DataPoint { + value: number + label: string + color: string +} + +export function PieChart(props: PieChartProps) { + useSetDayJsLocale() + const t = useTranslations('page.stats.pie') + + const otherLabel: string = '###OTHER###' + + const counts = aggregateTinyParts( props.counts, otherLabel) + const valueSum = arraySum(Object.values(counts.keyCounts)) // FIXME: handle if it's 0 + + const mainDataPoints: DataPoint[] = [] + const subkeyDataPoints: DataPoint[] = [] + { + const colorMapping: {[label: string]: string} = {} + Object.entries(counts.keyCounts).forEach(([key, value], index) => { + const color = colorPalette[index % colorPalette.length] + mainDataPoints.push({ value, label: key, color }) + colorMapping[key] = color + }) + Object.entries(counts.keyCounts).forEach(([key, value], _) => { + const entries = Object.entries(counts.subkeyCounts[key] || {}) + const midColor = colorMapping[key] + const colors: string[] = [] + const offsetRange = 90 * value / valueSum + entries.forEach((_, index) => { + const k = (index - Math.floor(entries.length / 2)) / entries.length + const sign = k >= 0 ? '+' : '' + const color = chroma(midColor).set('hsl.h', `${sign}${offsetRange * k}`).hex() + colors.push(color) + }) + entries.forEach(([subkey, subValue], index) => { + subkeyDataPoints.push({ value: subValue, label: subkey, color: colors[index]}) + }) + }) + + } + const datasets: ChartDataset[] = [{ + label: t('label'), + data: mainDataPoints.map(dp => dp.value), + hoverOffset: 4, + backgroundColor: mainDataPoints.map(dp => dp.color), + }] + + const largeSubkeyIndexes: Set = new Set() + if (subkeyDataPoints.length >= 1) { + datasets.push({ + label: t('label'), + data: subkeyDataPoints.map(dp => dp.value), + hoverOffset: 5, + backgroundColor: subkeyDataPoints.map(dp => dp.color), + }) + subkeyDataPoints.forEach((dp, index) => { + if (dp.value / valueSum >= innerRingMinRatio) { + largeSubkeyIndexes.add(index) + } + }) + } + + type SimpleContext = {datasetIndex: number, dataIndex: number} + function shouldDisplayDetails(ctx: SimpleContext): boolean { + return ctx.datasetIndex === 0 || (largeSubkeyIndexes.has(ctx.dataIndex) && getLabelRaw(ctx) !== otherLabel) + } + function getLabelRaw(ctx: SimpleContext): string { + if (ctx.datasetIndex === 0) { + return mainDataPoints[ctx.dataIndex].label + } else { + return subkeyDataPoints[ctx.dataIndex].label + } + } + function getLabel(ctx: SimpleContext): string { + const rawLabel = getLabelRaw(ctx) + return rawLabel !== otherLabel ? rawLabel : t('other') + } + + return <> + {/* Add a wrapper container to restrict the chart's size*/} +
+ getLabel(ctx[0]) + } + }, + title: { + display: true, + padding: { + top: 0, + bottom: 36, + }, + text: props.title, + font: { + size: 16, + }, + }, + legend: { + display: false, + }, + datalabels: { + display: true, + labels: { + index: { + display: shouldDisplayDetails, + align: (ctx) => ctx.datasetIndex === 0 ? 'end' : 'start', + anchor: (ctx) => ctx.datasetIndex === 0 ? 'end' : 'start', + formatter: (val, ctx) => getLabel(ctx as any), + }, + value: { + display: shouldDisplayDetails, + color: '#404040', + backgroundColor: '#ffffffcc', + borderColor: '#ffffff00', + borderWidth: 2, + borderRadius: 4, + padding: 0, + align: 'center', + formatter: (val, ctx) => `${Math.round(100 * val / valueSum)}%`, + }, + }, + color: '#000', + }, + }, + }} + data={{ + labels: Object.keys(counts.keyCounts), + datasets: datasets, + }} + /> +
+ +} diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index 4107359..27e8b49 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -8,7 +8,7 @@ import { siteConfig } from "@/site/config"; import { routes } from "@/site/routes"; import { Box, Burger } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import { IconBook2, IconExternalLink, IconHome, IconPackages, IconProps } from "@tabler/icons-react"; +import { IconBook2, IconChartBar, IconExternalLink, IconHome, IconPackages, IconProps } from "@tabler/icons-react"; import { clsx } from "clsx"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -16,7 +16,7 @@ import React from "react"; import styles from './navbar.module.css'; interface UrlProvider { - (key: string): string + (key: string): string } interface NavItem { @@ -42,6 +42,13 @@ const navItems: NavItem[] = [ isExternal: false, checkActive: (pathname: string) => pathname === routes.catalogue() || pathname.startsWith(routes.pluginBase() + '/'), }, + { + icon: IconChartBar, + key: 'stats', + href: (urls) => '/stats', + isExternal: false, + checkActive: (pathname: string) => pathname === routes.stats(), + }, { icon: IconBook2, key: 'docs', @@ -71,9 +78,9 @@ function NavbarLink({className, showIcon, item, ...props}: NavBarLinkProps) { {...props} >
- {showIcon && } + {showIcon && }

{t(item.key)}

- {item.isExternal && } + {item.isExternal && }
) diff --git a/src/messages/en.json b/src/messages/en.json index 35b0c79..7450f5d 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -11,6 +11,7 @@ "navigation": { "home": "Home", "plugins": "Plugins", + "stats": "Stats", "docs": "Document" }, "locale_switch": { @@ -166,9 +167,33 @@ "meta_source_latest_release": "Latest version {version}", "meta_source_branch": "Git branch {branch}" } + }, + "stats": { + "kind": { + "mcdr_instance": "MCDR Instance Count", + "mcdr_version": "MCDR Version", + "python_version": "Python Version", + "system_version": "Operation System", + "system_arch": "CPU Architecture", + "deployment_method": "Deployment method", + "country": "Country / Region" + }, + "line": { + "total": "Total" + }, + "pie": { + "other": "other", + "label": "amount" + } } }, "urls": { "document": "https://docs.mcdreforged.com/en" + }, + "utils": { + "iso3166": { + "xx": "Unknown", + "t1": "Tor" + } } } diff --git a/src/messages/zh-CN.json b/src/messages/zh-CN.json index 84c35a0..74d428c 100644 --- a/src/messages/zh-CN.json +++ b/src/messages/zh-CN.json @@ -11,6 +11,7 @@ "navigation": { "home": "主页", "plugins": "插件仓库", + "stats": "统计", "docs": "文档" }, "locale_switch": { @@ -166,9 +167,33 @@ "meta_source_latest_release": "最新版本 {version}", "meta_source_branch": "Git 的 {branch} 分支" } + }, + "stats": { + "kind": { + "mcdr_instance": "MCDR 运行实例数", + "mcdr_version": "MCDR 版本", + "python_version": "Python 版本", + "system_version": "操作系统", + "system_arch": "CPU 架构", + "deployment_method": "部署方式", + "country": "国家/地区" + }, + "line": { + "total": "总数" + }, + "pie": { + "other": "其他", + "label": "数量" + } } }, "urls": { "document": "https://docs.mcdreforged.com/zh-cn" + }, + "utils": { + "iso3166": { + "xx": "未知", + "t1": "Tor" + } } } diff --git a/src/site/routes.ts b/src/site/routes.ts index 6a6bff1..8bcc7aa 100644 --- a/src/site/routes.ts +++ b/src/site/routes.ts @@ -15,6 +15,8 @@ class Routes { pluginRelease(pluginId: string, version: string) { return this.plugin(pluginId) + '/release/' + version } + + stats = () => '/stats' } export const routes = new Routes() diff --git a/src/utils/environment-utils.ts b/src/utils/environment-utils.ts index 498d701..fe49522 100644 --- a/src/utils/environment-utils.ts +++ b/src/utils/environment-utils.ts @@ -1,3 +1,19 @@ export function isProduction() { return process.env.NODE_ENV === 'production' } + +export function shouldReadCatalogueEverythingFromLocalFile() { + return process.env.MW_USE_LOCAL_EVERYTHING === 'true' +} + +export function getCatalogueEverythingUrl() { + return process.env.MW_EVERYTHING_JSON_URL || 'https://raw.githubusercontent.com/MCDReforged/PluginCatalogue/meta/everything.json.gz' +} + +export function getRevalidateCatalogueToken() { + return process.env.MW_REVALIDATE_CATALOGUE_TOKEN +} + +export function getTelemetryApiToken() { + return process.env.MW_TELEMETRY_API_TOKEN || '' +} diff --git a/src/utils/iso-3166-utils.ts b/src/utils/iso-3166-utils.ts new file mode 100644 index 0000000..2e86eaf --- /dev/null +++ b/src/utils/iso-3166-utils.ts @@ -0,0 +1,33 @@ +import isoCountries from "i18n-iso-countries"; +import { getLocale, getTranslations } from "next-intl/server"; + +const localeToLibLang: {[locale: string]: string} = { + 'en': 'en', + 'zh-CN': 'zh', +} +const specialCodes = new Set([ + 'XX', 'T1', +]) + +// async is to ensure it's called at the server side +export async function getCountryCodeName(code: string): Promise { + // https://www.iso.org/iso-3166-country-codes.html + // https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#cf-ipcountry + // The CF-IPCountry header contains a two-character country code of the originating visitor’s country. + // Besides the ISO-3166-1 alpha-2 codes, Cloudflare uses the following special country codes: + // XX - Used for clients without country code data. + // T1 - Used for clients using the Tor network. + + if (code in specialCodes) { + const t = await getTranslations('utils.iso3166') + return t(code.toLowerCase()) + } + + const locale = await getLocale() + const lang = localeToLibLang[locale] + if (!lang) { + return undefined + } + + return isoCountries.getName(code, lang) +} diff --git a/src/utils/math-utils.ts b/src/utils/math-utils.ts new file mode 100644 index 0000000..1883777 --- /dev/null +++ b/src/utils/math-utils.ts @@ -0,0 +1,12 @@ +export function arraySum(numbers: number[]): number { + return numbers.reduce((v, s) => v + s, 0) +} + +export function arrayMax(numbers: number[]): number { + return numbers.reduce((max, v) => (v > max ? v : max), numbers[0]) +} + +export function arrayMin(numbers: number[]): number { + return numbers.reduce((min, v) => (v < min ? v : min), numbers[0]) +} +