diff --git a/app/package.json b/app/package.json index f59f2b65..055e072a 100644 --- a/app/package.json +++ b/app/package.json @@ -88,6 +88,7 @@ "react-dropzone": "^14.2.3", "react-hook-form": "7.43.0", "react-html-parser": "2.0.2", + "react-wordcloud": "^1.2.7", "replicate": "^0.25.2", "sanitize-html": "2.13.0", "sharp": "0.32.6", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index a728ba74..fd42a8ec 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: react-html-parser: specifier: 2.0.2 version: 2.0.2(react@18.2.0) + react-wordcloud: + specifier: ^1.2.7 + version: 1.2.7(react@18.2.0) replicate: specifier: ^0.25.2 version: 0.25.2 @@ -3121,6 +3124,21 @@ packages: resolution: {integrity: sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw==} engines: {node: '>=0.10'} + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-cloud@1.2.7: + resolution: {integrity: sha512-8TrgcgwRIpoZYQp7s3fGB7tATWfhckRb8KcVd1bOgqkNdkJRDGWfdSf4HkHHzZxSczwQJdSxvfPudwir5IAJ3w==} + + d3-color@1.4.1: + resolution: {integrity: sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==} + + d3-color@2.0.0: + resolution: {integrity: sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==} + + d3-dispatch@1.0.6: + resolution: {integrity: sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==} + d3-dispatch@3.0.1: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} engines: {node: '>=12'} @@ -3129,26 +3147,59 @@ packages: resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} engines: {node: '>=12'} + d3-ease@1.0.7: + resolution: {integrity: sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==} + d3-force@3.0.0: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} engines: {node: '>=12'} + d3-format@2.0.0: + resolution: {integrity: sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==} + d3-hierarchy@3.1.2: resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} engines: {node: '>=12'} + d3-interpolate@1.4.0: + resolution: {integrity: sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==} + + d3-interpolate@2.0.1: + resolution: {integrity: sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==} + d3-quadtree@3.0.1: resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} engines: {node: '>=12'} + d3-scale-chromatic@1.5.0: + resolution: {integrity: sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==} + + d3-scale@3.3.0: + resolution: {integrity: sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==} + + d3-selection@1.4.2: + resolution: {integrity: sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==} + d3-selection@3.0.0: resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} engines: {node: '>=12'} + d3-time-format@3.0.0: + resolution: {integrity: sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==} + + d3-time@2.1.1: + resolution: {integrity: sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==} + + d3-timer@1.0.10: + resolution: {integrity: sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==} + d3-timer@3.0.1: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@1.3.2: + resolution: {integrity: sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -3971,6 +4022,9 @@ packages: resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} engines: {node: '>= 0.4'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -4257,6 +4311,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -4954,6 +5014,11 @@ packages: '@types/react': optional: true + react-wordcloud@1.2.7: + resolution: {integrity: sha512-pyXvL8Iu2J258Qk2/kAwY23dIVhNpMC3dnvbXRkw5+Ert5EkJWwnwVjs9q8CmX38NWbfCKhGmpjuumBoQEtniw==} + peerDependencies: + react: ^16.13.0 + react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -4992,6 +5057,9 @@ packages: resolution: {integrity: sha512-X34iHADNbNDfr6OTStIAHWSAvvKQRYgLO6duASaVf7J2VA3lvmNYboAHOuLC2huav1IwgZJtyEcJCKVzFxOSMQ==} engines: {node: '>=8.6.0'} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5070,6 +5138,9 @@ packages: secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -5404,6 +5475,9 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + titleize@3.0.0: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} engines: {node: '>=12'} @@ -8711,6 +8785,20 @@ snapshots: cytoscape@3.30.2: {} + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-cloud@1.2.7: + dependencies: + d3-dispatch: 1.0.6 + + d3-color@1.4.1: {} + + d3-color@2.0.0: {} + + d3-dispatch@1.0.6: {} + d3-dispatch@3.0.1: {} d3-drag@3.0.0: @@ -8718,20 +8806,66 @@ snapshots: d3-dispatch: 3.0.1 d3-selection: 3.0.0 + d3-ease@1.0.7: {} + d3-force@3.0.0: dependencies: d3-dispatch: 3.0.1 d3-quadtree: 3.0.1 d3-timer: 3.0.1 + d3-format@2.0.0: {} + d3-hierarchy@3.1.2: {} + d3-interpolate@1.4.0: + dependencies: + d3-color: 1.4.1 + + d3-interpolate@2.0.1: + dependencies: + d3-color: 2.0.0 + d3-quadtree@3.0.1: {} + d3-scale-chromatic@1.5.0: + dependencies: + d3-color: 1.4.1 + d3-interpolate: 1.4.0 + + d3-scale@3.3.0: + dependencies: + d3-array: 2.12.1 + d3-format: 2.0.0 + d3-interpolate: 2.0.1 + d3-time: 2.1.1 + d3-time-format: 3.0.0 + + d3-selection@1.4.2: {} + d3-selection@3.0.0: {} + d3-time-format@3.0.0: + dependencies: + d3-time: 2.1.1 + + d3-time@2.1.1: + dependencies: + d3-array: 2.12.1 + + d3-timer@1.0.10: {} + d3-timer@3.0.1: {} + d3-transition@1.3.2: + dependencies: + d3-color: 1.4.1 + d3-dispatch: 1.0.6 + d3-ease: 1.0.7 + d3-interpolate: 1.4.0 + d3-selection: 1.4.2 + d3-timer: 1.0.10 + damerau-levenshtein@1.0.8: {} data-urls@5.0.0: @@ -9673,6 +9807,8 @@ snapshots: has: 1.0.3 side-channel: 1.0.4 + internmap@1.0.1: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -9949,6 +10085,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.clonedeep@4.5.0: {} + + lodash.debounce@4.0.8: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} @@ -10551,6 +10691,22 @@ snapshots: optionalDependencies: '@types/react': 18.2.48 + react-wordcloud@1.2.7(react@18.2.0): + dependencies: + d3-array: 2.12.1 + d3-cloud: 1.2.7 + d3-dispatch: 1.0.6 + d3-scale: 3.3.0 + d3-scale-chromatic: 1.5.0 + d3-selection: 1.4.2 + d3-transition: 1.3.2 + lodash.clonedeep: 4.5.0 + lodash.debounce: 4.0.8 + react: 18.2.0 + resize-observer-polyfill: 1.5.1 + seedrandom: 3.0.5 + tippy.js: 6.3.7 + react@18.2.0: dependencies: loose-envify: 1.4.0 @@ -10607,6 +10763,8 @@ snapshots: transitivePeerDependencies: - supports-color + resize-observer-polyfill@1.5.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -10720,6 +10878,8 @@ snapshots: secure-json-parse@2.7.0: {} + seedrandom@3.0.5: {} + semver@6.3.1: {} semver@7.5.4: @@ -11092,6 +11252,10 @@ snapshots: tinyspy@3.0.2: {} + tippy.js@6.3.7: + dependencies: + '@popperjs/core': 2.11.8 + titleize@3.0.0: {} tldts-core@6.1.47: {} diff --git a/app/src/app/manual/ai/completeness/page.tsx b/app/src/app/manual/ai/completeness/page.tsx new file mode 100644 index 00000000..6ea9f790 --- /dev/null +++ b/app/src/app/manual/ai/completeness/page.tsx @@ -0,0 +1,160 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import ContentBox from "@/layout/ContentBox"; +import NavTabs from "@/layout/NavTabs"; +import Loader from "@/layout/Loader"; +import { api } from "@/utils/api"; +import Table, { type ColumnDefinitionType } from "@/layout/Table"; +import WordCloud from "@/layout/Wordcloud"; +import { Chart as ChartJS } from "chart.js/auto"; +import type { ArrayElement } from "@/utils/typeutils"; + +export default function ManualBloodlineBalance() { + // State + const availFilters = ["Incomplete", "Diversity"]; + const [filter, setFilter] = useState<(typeof availFilters)[number]>("Incomplete"); + + // Queries + const { data, isPending } = api.bloodline.getAll.useQuery( + { limit: 500 }, + { staleTime: Infinity }, + ); + const allBloodlines = data?.data; + + // Table processing + const processed = allBloodlines + ?.map((bloodline) => { + // Checks + const classification = bloodline.statClassification ? 1 : 0; + const effects = bloodline.effects.length === 0 ? 1 : 0; + const shortDescription = bloodline.description.length < 50 ? 1 : 0; + const total = classification + effects; + // Return summary + return { + name: bloodline.name, + description: bloodline.description, + classification: classification ? "Yes" : "No", + effects: effects ? "Yes" : "No", + shortDescription: shortDescription ? "Yes" : "No", + total: total, + }; + }) + .filter((b) => b.total > 0) + .sort((a, b) => b.total - a.total); + + // Table + type Row = ArrayElement; + const columns: ColumnDefinitionType[] = [ + { key: "name", header: "Bloodline", type: "string" }, + { key: "classification", header: "N/A Class", type: "string" }, + { key: "effects", header: "N/A Effects", type: "string" }, + { key: "shortDescription", header: "Too Undescriptive", type: "string" }, + ]; + + // Counts per classification + const classCounts = allBloodlines?.reduce>((acc, curr) => { + const key = curr.statClassification || "N/A"; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + // Counts per rank + const rankCounts = allBloodlines?.reduce>((acc, curr) => { + const key = curr.rank || "N/A"; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + // Wordcloud + const allText = processed?.map((b) => b.description).join(" "); + + return ( + <> + + } + > +

+ The aim of this overview is to highlight any missing information in content, + such that we can ensure that content is complete & diverse. +

+ {isPending && } + {!isPending && filter === "Incomplete" && ( + + )} + {!isPending && filter === "Diversity" && ( +
+

Description Wordcloud

+ +

Classifications

+ +

Ranking

+ +
+ )} + + + ); +} + +interface CountsChartProps { + data: Record | undefined; +} + +const CountsChart: React.FC = (props) => { + const classChart = useRef(null); + const data = Object.entries(props.data || {}).map(([text, value]) => ({ + text, + value, + })); + const values = data.map((d) => d.value); + const labels = data.map((d) => d.text); + useEffect(() => { + const classCtx = classChart?.current?.getContext("2d"); + if (classCtx) { + const myClassChart = new ChartJS(classCtx, { + type: "bar", + options: { + maintainAspectRatio: false, + responsive: true, + aspectRatio: 1.1, + scales: { + y: { + beginAtZero: true, + }, + }, + plugins: { + legend: { + display: false, + }, + }, + }, + data: { + labels: labels, + datasets: [ + { + data: values, + borderWidth: 1, + }, + ], + }, + }); + // Remove on unmount + return () => { + myClassChart.destroy(); + }; + } + }, [labels, values]); + + return ( +
+ +
+ ); +}; diff --git a/app/src/app/manual/ai/page.tsx b/app/src/app/manual/ai/page.tsx index 03d11d46..61370eb1 100644 --- a/app/src/app/manual/ai/page.tsx +++ b/app/src/app/manual/ai/page.tsx @@ -7,7 +7,7 @@ import ItemWithEffects from "@/layout/ItemWithEffects"; import ContentBox from "@/layout/ContentBox"; import Loader from "@/layout/Loader"; import { Button } from "@/components/ui/button"; -import { FilePlus, Presentation } from "lucide-react"; +import { FilePlus, ChartCandlestick, ChartPie } from "lucide-react"; import { useInfinitePagination } from "@/libs/pagination"; import { api } from "@/utils/api"; import { showMutationToast } from "@/libs/toast"; @@ -89,12 +89,18 @@ export default function ManualAI() { subtitle="NPC Opponents" back_href="/manual" topRightContent={ - - - +
+ + + + + + +
} >

diff --git a/app/src/app/manual/bloodline/completeness/page.tsx b/app/src/app/manual/bloodline/completeness/page.tsx new file mode 100644 index 00000000..d4535367 --- /dev/null +++ b/app/src/app/manual/bloodline/completeness/page.tsx @@ -0,0 +1,160 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import ContentBox from "@/layout/ContentBox"; +import NavTabs from "@/layout/NavTabs"; +import Loader from "@/layout/Loader"; +import { api } from "@/utils/api"; +import Table, { type ColumnDefinitionType } from "@/layout/Table"; +import WordCloud from "@/layout/Wordcloud"; +import { Chart as ChartJS } from "chart.js/auto"; +import type { ArrayElement } from "@/utils/typeutils"; + +export default function ManualBloodlineBalance() { + // State + const availFilters = ["Incomplete", "Diversity"]; + const [filter, setFilter] = useState<(typeof availFilters)[number]>("Incomplete"); + + // Queries + const { data, isPending } = api.bloodline.getAll.useQuery( + { limit: 500 }, + { staleTime: Infinity }, + ); + const allBloodlines = data?.data; + + // Table processing + const processed = allBloodlines + ?.map((bloodline) => { + // Checks + const classification = bloodline.statClassification ? 1 : 0; + const effects = bloodline.effects.length === 0 ? 1 : 0; + const shortDescription = bloodline.description.length < 50 ? 1 : 0; + const total = classification + effects + shortDescription; + // Return summary + return { + name: bloodline.name, + description: bloodline.description, + classification: classification ? "Yes" : "No", + effects: effects ? "Yes" : "No", + shortDescription: shortDescription ? "Yes" : "No", + total: total, + }; + }) + .filter((b) => b.total > 0) + .sort((a, b) => b.total - a.total); + + // Table + type Row = ArrayElement; + const columns: ColumnDefinitionType[] = [ + { key: "name", header: "Bloodline", type: "string" }, + { key: "classification", header: "N/A Class", type: "string" }, + { key: "effects", header: "N/A Effects", type: "string" }, + { key: "shortDescription", header: "Too Undescriptive", type: "string" }, + ]; + + // Counts per classification + const classCounts = allBloodlines?.reduce>((acc, curr) => { + const key = curr.statClassification || "N/A"; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + // Counts per rank + const rankCounts = allBloodlines?.reduce>((acc, curr) => { + const key = curr.rank || "N/A"; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + // Wordcloud + const allText = processed?.map((b) => b.description).join(" "); + + return ( + <> + + } + > +

+ The aim of this overview is to highlight any missing information in content, + such that we can ensure that content is complete & diverse. +

+ {isPending && } + {!isPending && filter === "Incomplete" && ( +
+ )} + {!isPending && filter === "Diversity" && ( +
+

Description Wordcloud

+ +

Classifications

+ +

Ranking

+ +
+ )} + + + ); +} + +interface CountsChartProps { + data: Record | undefined; +} + +const CountsChart: React.FC = (props) => { + const classChart = useRef(null); + const data = Object.entries(props.data || {}).map(([text, value]) => ({ + text, + value, + })); + const values = data.map((d) => d.value); + const labels = data.map((d) => d.text); + useEffect(() => { + const classCtx = classChart?.current?.getContext("2d"); + if (classCtx) { + const myClassChart = new ChartJS(classCtx, { + type: "bar", + options: { + maintainAspectRatio: false, + responsive: true, + aspectRatio: 1.1, + scales: { + y: { + beginAtZero: true, + }, + }, + plugins: { + legend: { + display: false, + }, + }, + }, + data: { + labels: labels, + datasets: [ + { + data: values, + borderWidth: 1, + }, + ], + }, + }); + // Remove on unmount + return () => { + myClassChart.destroy(); + }; + } + }, [labels, values]); + + return ( +
+ +
+ ); +}; diff --git a/app/src/app/manual/bloodline/page.tsx b/app/src/app/manual/bloodline/page.tsx index b87a7611..ca715886 100644 --- a/app/src/app/manual/bloodline/page.tsx +++ b/app/src/app/manual/bloodline/page.tsx @@ -9,7 +9,8 @@ import Loader from "@/layout/Loader"; import MassEditContent from "@/layout/MassEditContent"; import BloodFiltering, { useFiltering, getFilter } from "@/layout/BloodlineFiltering"; import { Button } from "@/components/ui/button"; -import { FilePlus, SquarePen, Presentation } from "lucide-react"; +import { FilePlus, SquarePen, ChartCandlestick } from "lucide-react"; +import { ChartPie } from "lucide-react"; import { useInfinitePagination } from "@/libs/pagination"; import { api } from "@/utils/api"; import { showMutationToast } from "@/libs/toast"; @@ -71,12 +72,18 @@ export default function ManualBloodlines() { subtitle="What are they?" back_href="/manual" topRightContent={ - - - +
+ + + + + + +
} >

diff --git a/app/src/app/manual/item/balance/page.tsx b/app/src/app/manual/item/balance/page.tsx new file mode 100644 index 00000000..0bce0847 --- /dev/null +++ b/app/src/app/manual/item/balance/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { groupBy } from "@/utils/grouping"; +import ContentBox from "@/layout/ContentBox"; +import NavTabs from "@/layout/NavTabs"; +import Loader from "@/layout/Loader"; +import ExportGraph from "@/layout/ExportGraph"; +import { getUsageChart } from "@/layout/UsageStatistics"; +import { api } from "@/utils/api"; +import type { BattleTypes } from "@/drizzle/constants"; + +export default function ManualItemsBalance() { + // State + const [filter, setFilter] = useState<(typeof BattleTypes)[number]>("COMBAT"); + + // Reference for the chart + const chartRef = useRef(null); + + // Queries + const { data, isPending } = api.data.getItemBalanceStatistics.useQuery( + { battleType: filter }, + { staleTime: Infinity }, + ); + + useEffect(() => { + const ctx = chartRef?.current?.getContext("2d"); + if (ctx && data) { + const groups = groupBy(data, "name"); + const labels = Array.from(groups).map(([name, entries]) => [ + name, + `Count: ${entries.reduce((acc, curr) => acc + curr.count, 0)}`, + ]); + // const labels = Array.from(groups.keys()); + const myChart = getUsageChart(ctx, groups, labels); + myChart.resize(500, groups.size * 60); + return () => { + myChart.destroy(); + }; + } + }, [data]); + + return ( + <> + + } + > + Here we aim to give an overview of items usage & win-statistics, so as to make + it transparent if any items or combination of itemss is over/under-powered and + in need of balance adjustment. + {isPending && } +

+ +
+ {chartRef.current !== null && ( + + )} + + + ); +} diff --git a/app/src/app/manual/item/completeness/page.tsx b/app/src/app/manual/item/completeness/page.tsx new file mode 100644 index 00000000..6777f4eb --- /dev/null +++ b/app/src/app/manual/item/completeness/page.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import ContentBox from "@/layout/ContentBox"; +import NavTabs from "@/layout/NavTabs"; +import Loader from "@/layout/Loader"; +import { api } from "@/utils/api"; +import Table, { type ColumnDefinitionType } from "@/layout/Table"; +import WordCloud from "@/layout/Wordcloud"; +import { Chart as ChartJS } from "chart.js/auto"; +import type { ArrayElement } from "@/utils/typeutils"; + +export default function ManualBloodlineBalance() { + // State + const availFilters = ["Incomplete", "Diversity"]; + const [filter, setFilter] = useState<(typeof availFilters)[number]>("Incomplete"); + + // Queries + const { data, isPending } = api.item.getAll.useQuery( + { limit: 500 }, + { staleTime: Infinity }, + ); + const allItems = data?.data; + + // Table processing + const processed = allItems + ?.map((item) => { + // Checks + const effects = item.effects.length === 0 ? 1 : 0; + const shortDescription = item.description.length < 50 ? 1 : 0; + const battleDescription = item.battleDescription.length < 50 ? 1 : 0; + const missingGraphic = !item.effects.some( + (e) => + e.appearAnimation || + e.disappearAnimation || + e.staticAnimation || + e.staticAssetPath, + ) + ? 1 + : 0; + const total = effects + shortDescription + battleDescription + missingGraphic; + // Return summary + return { + name: item.name, + missingGraphic: missingGraphic ? "N/A" : "No", + battleDescription: battleDescription ? "Yes" : "No", + effects: effects ? "N/A" : "No", + shortDescription: shortDescription ? "Yes" : "No", + total: total, + }; + }) + .filter((b) => b.total > 0) + .sort((a, b) => b.total - a.total); + + // Table + type Row = ArrayElement; + const columns: ColumnDefinitionType[] = [ + { key: "name", header: "Bloodline", type: "string" }, + { key: "effects", header: "N/A Effects", type: "string" }, + { key: "missingGraphic", header: "N/A Graphic", type: "string" }, + { key: "shortDescription", header: "Short Desc", type: "string" }, + { key: "battleDescription", header: "Short Battle Desc", type: "string" }, + ]; + + // Counts per classification + const classCounts = allItems?.reduce>((acc, curr) => { + const key = curr.rarity || "N/A"; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + // Wordclouds + const allDescriptions = allItems?.map((b) => b.description).join(" "); + const allTitles = allItems?.map((b) => b.name).join(" "); + + return ( + <> + + } + > +

+ The aim of this overview is to highlight any missing information in content, + such that we can ensure that content is complete & diverse. +

+ {isPending && } + {!isPending && filter === "Incomplete" && ( +
+ )} + {!isPending && filter === "Diversity" && ( +
+

Description Wordcloud

+ +

Title Wordcloud

+ +

Rarity

+ +
+ )} + + + ); +} + +interface CountsChartProps { + data: Record | undefined; +} + +const CountsChart: React.FC = (props) => { + const classChart = useRef(null); + const data = Object.entries(props.data || {}).map(([text, value]) => ({ + text, + value, + })); + const values = data.map((d) => d.value); + const labels = data.map((d) => d.text); + useEffect(() => { + const classCtx = classChart?.current?.getContext("2d"); + if (classCtx) { + const myClassChart = new ChartJS(classCtx, { + type: "bar", + options: { + maintainAspectRatio: false, + responsive: true, + aspectRatio: 1.1, + scales: { + y: { + beginAtZero: true, + }, + }, + plugins: { + legend: { + display: false, + }, + }, + }, + data: { + labels: labels, + datasets: [ + { + data: values, + borderWidth: 1, + }, + ], + }, + }); + // Remove on unmount + return () => { + myClassChart.destroy(); + }; + } + }, [labels, values]); + + return ( +
+ +
+ ); +}; diff --git a/app/src/app/manual/item/page.tsx b/app/src/app/manual/item/page.tsx index 7337d756..ff4240d7 100644 --- a/app/src/app/manual/item/page.tsx +++ b/app/src/app/manual/item/page.tsx @@ -2,12 +2,13 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import ItemWithEffects from "@/layout/ItemWithEffects"; import ContentBox from "@/layout/ContentBox"; import Loader from "@/layout/Loader"; import MassEditContent from "@/layout/MassEditContent"; import { Button } from "@/components/ui/button"; -import { FilePlus, SquarePen } from "lucide-react"; +import { FilePlus, SquarePen, ChartCandlestick, ChartPie } from "lucide-react"; import { useInfinitePagination } from "@/libs/pagination"; import ItemFiltering, { useFiltering, getFilter } from "@/layout/ItemFiltering"; import { api } from "@/utils/api"; @@ -68,7 +69,25 @@ export default function ManualItems() { return ( <> - + + + + + + + + + } + >

In the treacherous world of ninja warfare, the mastery of jutsu alone is not enough to ensure victory. To become a truly formidable force, ninjas must @@ -82,6 +101,7 @@ export default function ManualItems() { {userData && canChangeContent(userData.role) && ( diff --git a/app/src/app/manual/jutsu/completeness/page.tsx b/app/src/app/manual/jutsu/completeness/page.tsx new file mode 100644 index 00000000..7e8fd004 --- /dev/null +++ b/app/src/app/manual/jutsu/completeness/page.tsx @@ -0,0 +1,174 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import ContentBox from "@/layout/ContentBox"; +import NavTabs from "@/layout/NavTabs"; +import Loader from "@/layout/Loader"; +import { api } from "@/utils/api"; +import Table, { type ColumnDefinitionType } from "@/layout/Table"; +import WordCloud from "@/layout/Wordcloud"; +import { Chart as ChartJS } from "chart.js/auto"; +import type { ArrayElement } from "@/utils/typeutils"; + +export default function ManualJutsuBalance() { + // State + const availFilters = ["Incomplete", "Diversity"]; + const [filter, setFilter] = useState<(typeof availFilters)[number]>("Incomplete"); + + // Queries + const { data, isPending } = api.jutsu.getAll.useQuery( + { limit: 500 }, + { staleTime: Infinity }, + ); + const allJutsus = data?.data; + + // Table processing + const processed = allJutsus + ?.map((jutsu) => { + // Checks + const effects = jutsu.effects.length === 0 ? 1 : 0; + const shortDescription = jutsu.description.length < 50 ? 1 : 0; + const battleDescription = jutsu.battleDescription.length < 50 ? 1 : 0; + const missingGraphic = !jutsu.effects.some( + (e) => + e.appearAnimation || + e.disappearAnimation || + e.staticAnimation || + e.staticAssetPath, + ) + ? 1 + : 0; + const total = effects + shortDescription + battleDescription + missingGraphic; + // Return summary + return { + name: jutsu.name, + description: jutsu.description, + missingGraphic: missingGraphic ? "N/A" : "No", + battleDescription: battleDescription ? "Yes" : "No", + effects: effects ? "N/A" : "No", + shortDescription: shortDescription ? "Yes" : "No", + total: total, + }; + }) + .filter((b) => b.total > 0) + .sort((a, b) => b.total - a.total); + + // Table + type Row = ArrayElement; + const columns: ColumnDefinitionType[] = [ + { key: "name", header: "Jutsu", type: "string" }, + { key: "effects", header: "N/A Effects", type: "string" }, + { key: "missingGraphic", header: "N/A Graphic", type: "string" }, + { key: "shortDescription", header: "Short Desc", type: "string" }, + { key: "battleDescription", header: "Short Battle Desc", type: "string" }, + ]; + + // Counts per classification + const classCounts = allJutsus?.reduce>((acc, curr) => { + const key = curr.statClassification || "N/A"; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + // Counts per rank + const rankCounts = allJutsus?.reduce>((acc, curr) => { + const key = curr.jutsuRank || "N/A"; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + // Wordclouds + const allDescriptions = allJutsus?.map((b) => b.description).join(" "); + const allTitles = allJutsus?.map((b) => b.name).join(" "); + + return ( + <> + + } + > +

+ The aim of this overview is to highlight any missing information in content, + such that we can ensure that content is complete & diverse. +

+ {isPending && } + {!isPending && filter === "Incomplete" && ( +
+ )} + {!isPending && filter === "Diversity" && ( +
+

Description Wordcloud

+ +

Title Wordcloud

+ +

Classifications

+ +

Ranking

+ +
+ )} + + + ); +} + +interface CountsChartProps { + data: Record | undefined; +} + +const CountsChart: React.FC = (props) => { + const classChart = useRef(null); + const data = Object.entries(props.data || {}).map(([text, value]) => ({ + text, + value, + })); + const values = data.map((d) => d.value); + const labels = data.map((d) => d.text); + useEffect(() => { + const classCtx = classChart?.current?.getContext("2d"); + if (classCtx) { + const myClassChart = new ChartJS(classCtx, { + type: "bar", + options: { + maintainAspectRatio: false, + responsive: true, + aspectRatio: 1.1, + scales: { + y: { + beginAtZero: true, + }, + }, + plugins: { + legend: { + display: false, + }, + }, + }, + data: { + labels: labels, + datasets: [ + { + data: values, + borderWidth: 1, + }, + ], + }, + }); + // Remove on unmount + return () => { + myClassChart.destroy(); + }; + } + }, [labels, values]); + + return ( +
+ +
+ ); +}; diff --git a/app/src/app/manual/jutsu/page.tsx b/app/src/app/manual/jutsu/page.tsx index a22b4578..e8570697 100644 --- a/app/src/app/manual/jutsu/page.tsx +++ b/app/src/app/manual/jutsu/page.tsx @@ -9,7 +9,7 @@ import Loader from "@/layout/Loader"; import MassEditContent from "@/layout/MassEditContent"; import JutsuFiltering, { useFiltering, getFilter } from "@/layout/JutsuFiltering"; import { Button } from "@/components/ui/button"; -import { FilePlus, SquarePen, Presentation } from "lucide-react"; +import { FilePlus, SquarePen, ChartCandlestick, ChartPie } from "lucide-react"; import { useInfinitePagination } from "@/libs/pagination"; import { api } from "@/utils/api"; import { showMutationToast } from "@/libs/toast"; @@ -71,12 +71,18 @@ export default function ManualJutsus() { subtitle="What are they?" back_href="/manual" topRightContent={ - - - +
+ + + + + + +
} >

diff --git a/app/src/components/ui/button.tsx b/app/src/components/ui/button.tsx index 01cce741..4eb2cb0c 100644 --- a/app/src/components/ui/button.tsx +++ b/app/src/components/ui/button.tsx @@ -1,5 +1,11 @@ import * as React from "react"; import Image from "next/image"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "src/libs/shadui"; @@ -39,25 +45,47 @@ export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; + hoverText?: string; decoration?: "gold" | "none"; animation?: "pulse"; } const Button = React.forwardRef( ( - { className, variant, size, asChild = false, decoration = "none", ...props }, + { + className, + variant, + size, + hoverText, + asChild = false, + decoration = "none", + ...props + }, ref, ) => { const Comp = asChild ? Slot : "button"; const animation = props.animation ? "animate-pulse hover:animate-none" : ""; - const element = ( + // Button element + let element = ( ); + if (hoverText) { + element = ( + + + {element} + {hoverText} + + + ); + } + // No decoration, just return button if (decoration === "none") return element; + // With decoration return (

{element} diff --git a/app/src/layout/Wordcloud.tsx b/app/src/layout/Wordcloud.tsx new file mode 100644 index 00000000..061be52d --- /dev/null +++ b/app/src/layout/Wordcloud.tsx @@ -0,0 +1,228 @@ +"use client"; + +import React from "react"; +import ReactWordcloud from "react-wordcloud"; + +interface WordCloudProps { + text: string | undefined; +} + +const WordCloud: React.FC = (props) => { + // Reduce to word frequency + const stopWords = [ + "and", + "the", + "of", + "in", + "with", + "their", + "they", + "to", + "a", + "is", + "for", + "her", + "him", + "by", + "through", + "into", + "that", + "this", + "them", + "as", + "these", + "from", + "who", + "its", + "within", + "an", + "p", + "are", + "his", + "on", + "at", + "be", + "or", + "it", + "s", + "was", + "which", + "will", + "have", + "has", + "not", + "but", + "also", + "can", + "could", + "would", + "should", + "may", + "might", + "shall", + "must", + "do", + "does", + "did", + "done", + "doing", + "am", + "are", + "is", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + "had", + "having", + "will", + "would", + "shall", + "should", + "may", + "might", + "must", + "can", + "could", + "do", + "does", + "did", + "doing", + "a", + "an", + "the", + "and", + "but", + "if", + "or", + "because", + "as", + "until", + "while", + "of", + "at", + "by", + "for", + "with", + "about", + "against", + "between", + "into", + "through", + "during", + "before", + "after", + "above", + "below", + "to", + "from", + "up", + "down", + "in", + "out", + "on", + "off", + "over", + "under", + "again", + "further", + "then", + "once", + "here", + "there", + "when", + "where", + "why", + "how", + "all", + "any", + "both", + "each", + "few", + "more", + "most", + "other", + "some", + "such", + "no", + "nor", + "not", + "only", + "own", + "same", + "so", + "than", + "too", + "very", + "s", + "t", + "can", + "will", + "just", + "don", + "should", + "now", + "d", + "ll", + "m", + "o", + "re", + "ve", + "y", + "ain", + "aren", + "couldn", + "didn", + "doesn", + "hadn", + "hasn", + "haven", + "isn", + "ma", + "mightn", + "mustn", + "needn", + "shan", + "shouldn", + "wasn", + "weren", + "won", + "wouldn", + "i", + "me", + "my", + "myself", + "we", + "our", + "ours", + "ourselves", + "you", + "your", + "new", + "user", + "opponent", + "enemy", + "description", + "target", + "jutsu", + ]; + const wordCounts = (props.text || "") + .split(" ") + .map((token) => token.toLowerCase().replace(/[^\w\s]/g, "")) + .filter((token) => !stopWords.includes(token)) + .reduce>((acc, curr) => { + acc[curr] = (acc[curr] || 0) + 1; + return acc; + }, {}); + const words = Object.entries(wordCounts).map(([text, value]) => ({ text, value })); + return ( +
+ +
+ ); +}; + +export default WordCloud; diff --git a/app/src/server/api/routers/data.ts b/app/src/server/api/routers/data.ts index 1276dcd8..7c206a98 100644 --- a/app/src/server/api/routers/data.ts +++ b/app/src/server/api/routers/data.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { and, eq, sql, asc, isNotNull } from "drizzle-orm"; import { userJutsu, userItem, userData, bloodline } from "@/drizzle/schema"; -import { dataBattleAction, jutsu } from "@/drizzle/schema"; +import { dataBattleAction, jutsu, item } from "@/drizzle/schema"; import { createTRPCRouter, publicProcedure, serverError } from "../trpc"; import { fetchJutsu } from "./jutsu"; import { fetchBloodline } from "./bloodline"; @@ -56,6 +56,27 @@ export const dataRouter = createTRPCRouter({ ); return usage; }), + getItemBalanceStatistics: publicProcedure + .input(z.object({ battleType: z.enum(BattleTypes) })) + .query(async ({ ctx, input }) => { + const usage = await ctx.drizzle + .select({ + name: item.name, + battleWon: dataBattleAction.battleWon, + count: sql`COUNT(${dataBattleAction.id})`.mapWith(Number), + }) + .from(dataBattleAction) + .leftJoin(item, eq(dataBattleAction.contentId, item.id)) + .groupBy(item.name, dataBattleAction.battleWon, dataBattleAction.battleType) + .where( + and( + eq(dataBattleAction.type, "item"), + isNotNull(item.name), + eq(dataBattleAction.battleType, input.battleType), + ), + ); + return usage; + }), getAiBalanceStatistics: publicProcedure .input(z.object({ battleType: z.enum(BattleTypes) })) .query(async ({ ctx, input }) => {