From 36a7922bd742044ca89df9ba433e05c65f950ad7 Mon Sep 17 00:00:00 2001 From: Sebastian Sebald Date: Wed, 26 Jul 2023 23:31:28 +0200 Subject: [PATCH] feat: composition details (#218) --- .prettierignore | 3 + app/_components/mobile-navigation.tsx | 7 + .../[id]/_component/pilot-details.tsx | 121 ++++ .../[id]/_component/squad-groups.tsx | 76 +++ .../[id]/_component/trend-curve.tsx | 74 +++ app/analyze/composition/[id]/layout.tsx | 13 + app/analyze/composition/[id]/page.tsx | 165 ++++++ app/analyze/composition/page.tsx | 55 +- app/analyze/page.tsx | 3 +- app/globals.css | 2 +- lib/config.ts | 8 +- lib/data/standard-loadout-pilots.json | 552 +++++------------- ...tandard-legal.json => standard-ships.json} | 0 lib/get-value.ts | 20 +- lib/stats/details/composition.ts | 380 ++++++++++++ lib/utils/date.utils.ts | 12 + lib/xws.ts | 11 +- next.config.js | 10 +- package.json | 2 + pnpm-lock.yaml | 102 +++- postcss.config.js | 2 +- scripts/update-xwing-data.mjs | 10 +- tailwind.config.js | 14 + ui/accordion.tsx | 78 +++ ui/badge.tsx | 36 ++ ui/button.tsx | 9 +- ui/card.tsx | 44 +- ui/collapsible.tsx | 6 +- ui/detail.tsx | 87 +++ ui/dialog.tsx | 6 +- ui/icons.tsx | 73 +++ ui/index.ts | 5 + ui/link.tsx | 1 + ui/pilot-image.tsx | 26 + ui/ship-icon.tsx | 2 +- ui/squad-list.tsx | 79 +++ ui/squad.tsx | 62 +- ui/stats/composition-stats.tsx | 149 ----- .../composition-stats/composition-filter.tsx | 28 + .../composition-stats/composition-stats.tsx | 32 + .../composition-stats/composition-table.tsx | 117 ++++ ui/stats/composition-stats/context.tsx | 49 ++ ui/stats/composition-stats/index.ts | 5 + ui/stats/composition-stats/types.ts | 14 + ui/stats/filter.tsx | 8 +- ui/table.tsx | 2 +- ui/timeline.tsx | 68 +++ 47 files changed, 1979 insertions(+), 649 deletions(-) create mode 100644 .prettierignore create mode 100644 app/analyze/composition/[id]/_component/pilot-details.tsx create mode 100644 app/analyze/composition/[id]/_component/squad-groups.tsx create mode 100644 app/analyze/composition/[id]/_component/trend-curve.tsx create mode 100644 app/analyze/composition/[id]/layout.tsx create mode 100644 app/analyze/composition/[id]/page.tsx rename lib/data/{standard-legal.json => standard-ships.json} (100%) create mode 100644 lib/stats/details/composition.ts create mode 100644 ui/accordion.tsx create mode 100644 ui/badge.tsx create mode 100644 ui/detail.tsx create mode 100644 ui/pilot-image.tsx create mode 100644 ui/squad-list.tsx delete mode 100644 ui/stats/composition-stats.tsx create mode 100644 ui/stats/composition-stats/composition-filter.tsx create mode 100644 ui/stats/composition-stats/composition-stats.tsx create mode 100644 ui/stats/composition-stats/composition-table.tsx create mode 100644 ui/stats/composition-stats/context.tsx create mode 100644 ui/stats/composition-stats/index.ts create mode 100644 ui/stats/composition-stats/types.ts create mode 100644 ui/timeline.tsx diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..bc143416 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +.next +node_modules +pnpm-lock.yaml \ No newline at end of file diff --git a/app/_components/mobile-navigation.tsx b/app/_components/mobile-navigation.tsx index 1071ea90..2b2afc38 100644 --- a/app/_components/mobile-navigation.tsx +++ b/app/_components/mobile-navigation.tsx @@ -73,6 +73,13 @@ export const MobileNavigation = () => { Pattern Analyzer
+ + Home + {[...siteNavigation, ...secondaryNavigation].map(({ name, href }) => ( { + const [grouped, setGrouped] = useState(true); + + const data = Object.entries(value); + + if (grouped) { + data.sort(([, a], [, b]) => { + const result = ships.indexOf(a.ship) - ships.indexOf(b.ship); + return result !== 0 ? result : b.percentile - a.percentile; + }); + } + + return ( + + + Pilots + + + + + {data.map(([pid, current]) => ( +
+ +
+ {getPilotName(pid)} +
+
+ + + + +
+
+
+ Loadout Performance +
+ {isStandardized(pid) ? ( +
+ Standarized Pilot. No + variations. +
+ ) : ( +
+ {current.upgrades.map(({ id, list, count, percentile }) => ( + +
+ {toPercentage(percentile)} ({count}) +
+
+ {upgradesToList(list)} +
+
+ ))} +
+ )} +
+
+ ))} +
+
+
+ ); +}; diff --git a/app/analyze/composition/[id]/_component/squad-groups.tsx b/app/analyze/composition/[id]/_component/squad-groups.tsx new file mode 100644 index 00000000..86e51e63 --- /dev/null +++ b/app/analyze/composition/[id]/_component/squad-groups.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { Accordion, Badge, CopyButton, Detail, Squad, Timeline } from '@/ui'; + +import { getPilotName } from '@/lib/get-value'; +import { type SquadCompositionStats } from '@/lib/stats/details/composition'; +import { formatDate } from '@/lib/utils/date.utils'; +import { toPercentage } from '@/lib/utils/math.utils'; +import { Copy } from '@/ui/icons'; + +// Props +// --------------- +export interface SquadGroupsProps { + value: SquadCompositionStats['squads']; +} + +// Component +// --------------- +export const SquadGroups = ({ value }: SquadGroupsProps) => { + const data = Object.entries(value); + data.sort(([, a], [, b]) => b.percentile - a.percentile); + + return ( + + {Object.entries(value).map(([id, current]) => ( + + +
+ {current.items.length} +
+ {id.split('.').map(getPilotName).join(', ')} +
+ +
+
+ + + +
+ + {current.items.map(({ date, player, xws }) => ( + + + {formatDate(new Date(date))} + by {player} + + + + + Copy XWS + + + + ))} + +
+
+
+ ))} +
+ ); +}; diff --git a/app/analyze/composition/[id]/_component/trend-curve.tsx b/app/analyze/composition/[id]/_component/trend-curve.tsx new file mode 100644 index 00000000..867a8f51 --- /dev/null +++ b/app/analyze/composition/[id]/_component/trend-curve.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { toPercentage } from '@/lib/utils'; +import { formatMonth } from '@/lib/utils/date.utils'; +import { linearGradientDef } from '@nivo/core'; +import { ResponsiveLine } from '@nivo/line'; + +// Props +// --------------- +export interface TrendCurveProps { + value: { + /** + * Date format YYYY-MM + */ + date: string; + count: number; + percentile: number; + }[]; +} + +// Components +// --------------- +export const TrendCurve = ({ value }: TrendCurveProps) => { + const data = value.map(({ date, percentile, count }) => ({ + x: date, + y: percentile, + count, + })); + + return ( +
+ Percentile, + legendPosition: 'middle', + legendOffset: -45, + }} + pointLabel={({ y }) => toPercentage(y as number)} + enablePointLabel + enableArea + enableGridX={false} + pointSize={8} + defs={[ + linearGradientDef('gradient', [ + { offset: 0, color: '#5155b1' }, + { offset: 100, color: '#96a6e3' }, + ]), + ]} + colors="#5155b1" + fill={[{ match: '*', id: 'gradient' }]} + margin={{ top: 20, right: 30, bottom: 30, left: 60 }} + isInteractive={false} + animate={false} + /> +
+ ); +}; diff --git a/app/analyze/composition/[id]/layout.tsx b/app/analyze/composition/[id]/layout.tsx new file mode 100644 index 00000000..d539c0e8 --- /dev/null +++ b/app/analyze/composition/[id]/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; +import { Title } from '@/ui'; + +const Layout = ({ children }: { children: ReactNode }) => ( + <> +
+ Composition Details +
+
{children}
+ +); + +export default Layout; diff --git a/app/analyze/composition/[id]/page.tsx b/app/analyze/composition/[id]/page.tsx new file mode 100644 index 00000000..bd22fa4d --- /dev/null +++ b/app/analyze/composition/[id]/page.tsx @@ -0,0 +1,165 @@ +import { baseUrl, pointsUpdateDate } from '@/lib/config'; +import { getShipName } from '@/lib/get-value'; +import { compositionDetails } from '@/lib/stats/details/composition'; +import { toPercentage } from '@/lib/utils'; +import { fromDate } from '@/lib/utils/date.utils'; +import { getAllTournaments, getSquads } from '@/lib/vendor/listfortress'; + +import { Card, Detail, ShipIcon } from '@/ui'; + +import { PilotDetails } from './_component/pilot-details'; +import { SquadGroups } from './_component/squad-groups'; +import { TrendCurve } from './_component/trend-curve'; + +// Config +// --------------- +export const revalidate = 21600; // 6 hours + +/** + * Opt into background revalidation. (see: https://github.com/vercel/next.js/discussions/43085) + */ +export const generateStaticParams = async () => { + const tournaments = await getAllTournaments({ + from: fromDate(pointsUpdateDate), + format: 'standard', + }); + + const squads = await Promise.all( + tournaments.map(({ id }) => getSquads({ id: `${id}` })) + ); + + const compositions = new Set(); + squads.flat().forEach(({ xws }) => { + if (!xws) return; + + const id = xws.pilots.map(({ ship }) => ship).join('.'); + compositions.add(id); + }); + + return [...compositions.values()].map(id => ({ + id, + })); +}; + +// Metadata +// --------------- +export const metadata = { + title: 'Pattern Analyzer | Composition Details', + description: 'Take a look at what is currently flown in X-Wing!', + openGraph: { + siteName: 'Pattern Analyzer', + title: 'Composition Details', + description: 'Take a look at what is currently flown in X-Wing!', + images: `${baseUrl}/api/og.png`, + locale: 'en-US', + type: 'website', + }, +}; + +// Data +// --------------- +const getCompositionStats = async (id: string, from: Date) => { + const tournaments = await getAllTournaments({ + from, + format: 'standard', + }); + + const data = await Promise.all( + tournaments.map(({ id, date }) => + getSquads({ id: `${id}` }).then(squads => ({ date, squads })) + ) + ); + + return compositionDetails(id, data); +}; + +// Props +// --------------- +interface PageParams { + params: { + id: string; + }; +} + +// Page +// --------------- +const Page = async ({ params }: PageParams) => { + const stats = await getCompositionStats( + params.id, + fromDate(pointsUpdateDate) + ); + + return ( +
+ + + Overview + + + + {stats.ships.map((ship, idx) => ( +
+ + + {getShipName(ship)} + +
+ ))} +
+ } + /> +
+ + + + + +
+ + + + + Trend + + + + + + + + + Squads + + + + + +
+ ); +}; + +export default Page; diff --git a/app/analyze/composition/page.tsx b/app/analyze/composition/page.tsx index 2c9055fb..0cc7c11d 100644 --- a/app/analyze/composition/page.tsx +++ b/app/analyze/composition/page.tsx @@ -5,10 +5,14 @@ import { baseUrl, pointsUpdateDate } from '@/lib/config'; import { formatDate, fromDate, toDate, today } from '@/lib/utils/date.utils'; import { getAllTournaments, getSquads } from '@/lib/vendor/listfortress'; -import { Caption, Inline, Message, Title } from '@/ui'; +import { Caption, Card, Inline, Message, Title } from '@/ui'; import { Calendar, Rocket, Trophy } from '@/ui/icons'; -import { CompositionStats } from '@/ui/stats/composition-stats'; +import { + CompositionFilter, + CompositionFilterProvider, + CompositionTable, +} from '@/ui/stats/composition-stats'; import { Filter } from '@/ui/stats/filter'; import { StatsHint } from '@/ui/stats/stats-hint'; import { setup } from '@/lib/stats'; @@ -24,12 +28,12 @@ export const revalidate = 21600; // 6 hours // Metadata // --------------- export const metadata = { - title: 'Pattern Analyzer | Analyze', - description: 'Analyze the current X-Wing meta!', + title: 'Pattern Analyzer | Compositions', + description: 'Take a look at what is currently flown in X-Wing!', openGraph: { siteName: 'Pattern Analyzer', - title: 'Analyze', - description: 'Analyze the current X-Wing meta!', + title: 'Compositions', + description: 'Take a look at what is currently flown in X-Wing!', images: `${baseUrl}/api/og.png`, locale: 'en-US', type: 'website', @@ -85,7 +89,7 @@ interface AnalyzePageProps { // Page // --------------- -const AnalyzePage = async ({ searchParams }: AnalyzePageProps) => { +const AnalyzeCompositionPage = async ({ searchParams }: AnalyzePageProps) => { const params = schema.safeParse(searchParams); if (!params.success) { @@ -111,7 +115,7 @@ const AnalyzePage = async ({ searchParams }: AnalyzePageProps) => { return ( <>
- Composition + Compositions @@ -128,20 +132,31 @@ const AnalyzePage = async ({ searchParams }: AnalyzePageProps) => {
- -
-
- + + + + +
+
+ + + + + +
+
+ +
-
- -
-
+ ); }; -export default AnalyzePage; +export default AnalyzeCompositionPage; diff --git a/app/analyze/page.tsx b/app/analyze/page.tsx index 1df55abf..6a97af0a 100644 --- a/app/analyze/page.tsx +++ b/app/analyze/page.tsx @@ -112,9 +112,8 @@ const getStats = cache( const squads = await Promise.all( tournaments.map(({ id }) => getSquads({ id: `${id}` })) ); - let stats = create(squads, { smallSamples }); - return stats; + return create(squads, { smallSamples }); } ); diff --git a/app/globals.css b/app/globals.css index bd6213e1..b5c61c95 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,3 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; diff --git a/lib/config.ts b/lib/config.ts index 339bd5ff..090fba4f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -11,14 +11,14 @@ export const vendors = [ ]; export const siteNavigation = [ - { - name: 'Home', - href: '/', - }, { name: 'Analyze', href: '/analyze', }, + { + name: 'Compositions', + href: '/analyze/composition', + }, ]; export const secondaryNavigation = [ diff --git a/lib/data/standard-loadout-pilots.json b/lib/data/standard-loadout-pilots.json index e4b5c3fc..ae67792e 100644 --- a/lib/data/standard-loadout-pilots.json +++ b/lib/data/standard-loadout-pilots.json @@ -2,683 +2,401 @@ "dextiree-battleofyavin": { "points": 4, "upgrades": { - "turret": [ - "dorsalturret" - ], - "torpedo": [ - "advprotontorpedoes" - ], - "astromech": [ - "r4astromech" - ] + "turret": ["dorsalturret"], + "torpedo": ["advprotontorpedoes"], + "astromech": ["r4astromech"] } }, "dutchvander-battleofyavin": { "points": 4, "upgrades": { - "turret": [ - "ioncannonturret" - ], - "torpedo": [ - "advprotontorpedoes" - ], - "unknown": [ - "targetingastromech" - ] + "turret": ["ioncannonturret"], + "torpedo": ["advprotontorpedoes"], + "unknown": ["targetingastromech"] } }, "holokand-battleofyavin": { "points": 3, "upgrades": { - "turret": [ - "dorsalturret" - ], - "torpedo": [ - "advprotontorpedoes" - ], - "unknown": [ - "preciseastromech" - ] + "turret": ["dorsalturret"], + "torpedo": ["advprotontorpedoes"], + "unknown": ["preciseastromech"] } }, "popskrail-battleofyavin": { "points": 4, "upgrades": { - "turret": [ - "ioncannonturret" - ], - "torpedo": [ - "advprotontorpedoes" - ], - "astromech": [ - "r4astromech" - ] + "turret": ["ioncannonturret"], + "torpedo": ["advprotontorpedoes"], + "astromech": ["r4astromech"] } }, "dutchvander-swz106": { "points": 4, "upgrades": { - "turret": [ - "ioncannonturret" - ], - "device": [ - "protonbombs" - ] + "turret": ["ioncannonturret"], + "device": ["protonbombs"] } }, "hortonsalm-swz106": { "points": 4, "upgrades": { - "turret": [ - "ioncannonturret" - ], - "device": [ - "proximitymines" - ] + "turret": ["ioncannonturret"], + "device": ["proximitymines"] } }, "hansolo-battleofyavin": { "points": 7, "upgrades": { - "crew": [ - "chewbacca-battleofyavin" - ], - "illicit": [ - "riggedcargochute" - ], - "title": [ - "millenniumfalcon" - ], - "configuration": [ - "l337sprogramming-battleofyavin" - ] + "crew": ["chewbacca-battleofyavin"], + "illicit": ["riggedcargochute"], + "title": ["millenniumfalcon"], + "configuration": ["l337sprogramming-battleofyavin"] } }, "arvelcrynyd-swz106": { "points": 4, "upgrades": { - "talent": [ - "predator" - ], - "modification": [ - "afterburners" - ] + "talent": ["predator"], + "modification": ["afterburners"] } }, "jakefarrell-swz106": { "points": 5, "upgrades": { - "talent": [ - "elusive", - "outmaneuver" - ], - "missile": [ - "ionmissiles" - ] + "talent": ["elusive", "outmaneuver"], + "missile": ["ionmissiles"] } }, "sharabey-swz106": { "points": 4, "upgrades": { - "talent": [ - "hopeful" - ], - "missile": [ - "concussionmissiles" - ] + "talent": ["hopeful"], + "missile": ["concussionmissiles"] } }, "biggsdarklighter-battleofyavin": { "points": 5, "upgrades": { - "talent": [ - "attackspeed-battleofyavin", - "selfless" - ], - "torpedo": [ - "protontorpedoes" - ], - "astromech": [ - "r2f2-battleofyavin" - ] + "talent": ["attackspeed-battleofyavin", "selfless"], + "torpedo": ["protontorpedoes"], + "astromech": ["r2f2-battleofyavin"] } }, "garvendreis-battleofyavin": { "points": 4, "upgrades": { - "torpedo": [ - "advprotontorpedoes" - ], - "astromech": [ - "r5k6-battleofyavin" - ] + "torpedo": ["advprotontorpedoes"], + "astromech": ["r5k6-battleofyavin"] } }, "jekporkins-battleofyavin": { "points": 4, "upgrades": { - "torpedo": [ - "advprotontorpedoes" - ], - "astromech": [ - "r5d8-battleofyavin" - ], - "modification": [ - "unstablesublightengines-battleofyavin" - ] + "torpedo": ["advprotontorpedoes"], + "astromech": ["r5d8-battleofyavin"], + "modification": ["unstablesublightengines-battleofyavin"] } }, "lukeskywalker-battleofyavin": { "points": 5, "upgrades": { - "talent": [ - "attackspeed-battleofyavin" - ], - "unknown": [ - "instictiveaim" - ], - "torpedo": [ - "protontorpedoes" - ], - "astromech": [ - "r2d2-battleofyavin" - ] + "talent": ["attackspeed-battleofyavin"], + "unknown": ["instictiveaim"], + "torpedo": ["protontorpedoes"], + "astromech": ["r2d2-battleofyavin"] } }, "wedgeantilles-battleofyavin": { "points": 5, "upgrades": { - "talent": [ - "attackspeed-battleofyavin", - "marksmanship" - ], - "torpedo": [ - "protontorpedoes" - ], - "astromech": [ - "r2a3-battleofyavin" - ] + "talent": ["attackspeed-battleofyavin", "marksmanship"], + "torpedo": ["protontorpedoes"], + "astromech": ["r2a3-battleofyavin"] } }, "lukeskywalker-swz106": { "points": 6, "upgrades": { - "unknown": [ - "instictiveaim" - ], - "torpedo": [ - "protontorpedoes" - ], - "astromech": [ - "r2d2" - ] + "unknown": ["instictiveaim"], + "torpedo": ["protontorpedoes"], + "astromech": ["r2d2"] } }, "jekporkins-swz106": { "points": 5, "upgrades": { - "unknown": [ - "predate" - ], - "torpedo": [ - "protontorpedoes" - ], - "astromech": [ - "r5d8-battleofyavin" - ] + "unknown": ["predate"], + "torpedo": ["protontorpedoes"], + "astromech": ["r5d8-battleofyavin"] } }, "darthvader-battleofyavin": { "points": 6, "upgrades": { - "talent": [ - "marksmanship" - ], - "force-power": [ - "hate" - ], - "modification": [ - "afterburners" - ] + "talent": ["marksmanship"], + "force-power": ["hate"], + "modification": ["afterburners"] } }, "darthvader-swz105": { "points": 6, "upgrades": { - "force-power": [ - "hate" - ], - "missile": [ - "ionmissiles" - ], - "modification": [ - "afterburners" - ] + "force-power": ["hate"], + "missile": ["ionmissiles"], + "modification": ["afterburners"] } }, "maarekstele-swz105": { "points": 5, "upgrades": { - "talent": [ - "elusive", - "outmaneuver" - ], - "modification": [ - "afterburners" - ] + "talent": ["elusive", "outmaneuver"], + "modification": ["afterburners"] } }, "idenversio-battleofyavin": { "points": 5, "upgrades": { - "talent": [ - "predator", - "fanatic-battleofyavin" - ] + "talent": ["predator", "fanatic-battleofyavin"] } }, "sigma4-battleofyavin": { "points": 4, "upgrades": { - "talent": [ - "disciplined" - ], - "tech": [ - "primedthrusters" - ] + "talent": ["disciplined"], + "tech": ["primedthrusters"] } }, "sigma5-battleofyavin": { "points": 4, "upgrades": { - "modification": [ - "sensorjammer-battleofyavin" - ], - "talent": [ - "elusive" - ] + "modification": ["sensorjammer-battleofyavin"], + "talent": ["elusive"] } }, "sigma6-battleofyavin": { "points": 4, "upgrades": { - "talent": [ - "daredevil" - ], - "modification": [ - "afterburners" - ] + "talent": ["daredevil"], + "modification": ["afterburners"] } }, "sigma7-battleofyavin": { "points": 4, "upgrades": { - "talent": [ - "marksmanship" - ], - "sensor": [ - "firecontrolsystem" - ] + "talent": ["marksmanship"], + "sensor": ["firecontrolsystem"] } }, "backstabber-battleofyavin": { "points": 4, "upgrades": { - "talent": [ - "crackshot", - "disciplined" - ], - "modification": [ - "afterburners" - ] + "talent": ["crackshot", "disciplined"], + "modification": ["afterburners"] } }, "darkcurse-battleofyavin": { "points": 4, "upgrades": { - "talent": [ - "ruthless" - ], - "modification": [ - "precisionionengines" - ] + "talent": ["ruthless"], + "modification": ["precisionionengines"] } }, "maulermithel-battleofyavin": { "points": 3, "upgrades": { - "talent": [ - "predator" - ], - "modification": [ - "afterburners" - ] + "talent": ["predator"], + "modification": ["afterburners"] } }, "wampa-battleofyavin": { "points": 3, "upgrades": { - "talent": [ - "elusive", - "vengeful-battleofyavin" - ] + "talent": ["elusive", "vengeful-battleofyavin"] } }, "idenversio-swz105": { "points": 4, "upgrades": { - "talent": [ - "disciplined", - "elusive" - ] + "talent": ["disciplined", "elusive"] } }, "nightbeast-swz105": { "points": 3, "upgrades": { - "talent": [ - "disciplined", - "predator" - ] + "talent": ["disciplined", "predator"] } }, "valenrudor-swz105": { "points": 3, "upgrades": { - "talent": [ - "disciplined" - ], - "modification": [ - "precisionionengines" - ] + "talent": ["disciplined"], + "modification": ["precisionionengines"] } }, "captainjonus-swz105": { "points": 4, "upgrades": { - "talent": [ - "feedbackping" - ], - "torpedo": [ - "plasmatorpedoes" - ], - "device": [ - "protonbombs" - ] + "talent": ["feedbackping"], + "torpedo": ["plasmatorpedoes"], + "device": ["protonbombs"] } }, "tomaxbren-swz105": { "points": 5, "upgrades": { - "talent": [ - "elusive" - ], - "missile": [ - "barragerockets" - ], - "device": [ - "proximitymines" - ] + "talent": ["elusive"], + "missile": ["barragerockets"], + "device": ["proximitymines"] } }, "oddball-siegeofcoruscant": { "points": 4, "upgrades": { - "talent": [ - "selfless" - ], - "gunner": [ - "veterantailgunner" - ], - "astromech": [ - "r4pastromech" - ] + "talent": ["selfless"], + "gunner": ["veterantailgunner"], + "astromech": ["r4pastromech"] } }, "wolffe-siegeofcoruscant": { "points": 4, "upgrades": { - "crew": [ - "wolfpack-siegeofcoruscant" - ], - "gunner": [ - "veterantailgunner" - ], - "astromech": [ - "q7astromech" - ] + "crew": ["wolfpack-siegeofcoruscant"], + "gunner": ["veterantailgunner"], + "astromech": ["q7astromech"] } }, "jag-siegeofcoruscant": { "points": 4, "upgrades": { - "gunner": [ - "veterantailgunner" - ], - "astromech": [ - "r4pastromech" - ], - "modification": [ - "synchronizedconsole" - ] + "gunner": ["veterantailgunner"], + "astromech": ["r4pastromech"], + "modification": ["synchronizedconsole"] } }, "kickback-siegeofcoruscant": { "points": 3, "upgrades": { - "missile": [ - "diamondboronmissiles" - ], - "modification": [ - "munitionsfailsafe" - ] + "missile": ["diamondboronmissiles"], + "modification": ["munitionsfailsafe"] } }, "axe-siegeofcoruscant": { "points": 3, "upgrades": { - "talent": [ - "deadeyeshot" - ], - "missile": [ - "barragerockets" - ] + "talent": ["deadeyeshot"], + "missile": ["barragerockets"] } }, "anakinskywalker-siegeofcoruscant": { "points": 4, "upgrades": { - "force-power": [ - "malice" - ], - "cannon": [ - "ancillaryionweapons-siegeofcoruscant" - ], - "astromech": [ - "r2d2-republic" - ] + "force-power": ["malice"], + "cannon": ["ancillaryionweapons-siegeofcoruscant"], + "astromech": ["r2d2-republic"] } }, "obiwankenobi-siegeofcoruscant": { "points": 4, "upgrades": { - "force-power": [ - "patience" - ], - "cannon": [ - "ancillaryionweapons-siegeofcoruscant" - ], - "astromech": [ - "r4p17-siegeofcoruscant" - ] + "force-power": ["patience"], + "cannon": ["ancillaryionweapons-siegeofcoruscant"], + "astromech": ["r4p17-siegeofcoruscant"] } }, "shaakti-siegeofcoruscant": { "points": 4, "upgrades": { - "talent": [ - "marksmanship" - ], - "force-power": [ - "brilliantevasion" - ], - "cannon": [ - "ancillaryionweapons-siegeofcoruscant" - ], - "astromech": [ - "r4pastromech" - ] + "talent": ["marksmanship"], + "force-power": ["brilliantevasion"], + "cannon": ["ancillaryionweapons-siegeofcoruscant"], + "astromech": ["r4pastromech"] } }, "klick-siegeofcoruscant": { "points": 4, "upgrades": { - "astromech": [ - "r3astromech" - ], - "modification": [ - "precisionionengines" - ], - "configuration": [ - "alpha3eesk" - ] + "astromech": ["r3astromech"], + "modification": ["precisionionengines"], + "configuration": ["alpha3eesk"] } }, "contrail-siegeofcoruscant": { "points": 4, "upgrades": { - "talent": [ - "ionlimiteroverride" - ], - "astromech": [ - "preciseastromech-battleofyavin" - ], - "device": [ - "ionbombs" - ], - "configuration": [ - "alpha3bbesh" - ] + "talent": ["ionlimiteroverride"], + "astromech": ["preciseastromech-battleofyavin"], + "device": ["ionbombs"], + "configuration": ["alpha3bbesh"] } }, "dfs081-siegeofcoruscant": { "points": 2, "upgrades": { - "missile": [ - "discordmissiles" - ], - "modification": [ - "contingencyprotocol-siegeofcoruscant" - ], - "configuration": [ - "strutlockoverride-siegeofcoruscant" - ] + "missile": ["discordmissiles"], + "modification": ["contingencyprotocol-siegeofcoruscant"], + "configuration": ["strutlockoverride-siegeofcoruscant"] } }, "dfs311-siegeofcoruscant": { "points": 3, "upgrades": { - "missile": [ - "discordmissiles" - ], - "modification": [ - "contingencyprotocol-siegeofcoruscant" - ], - "configuration": [ - "strutlockoverride-siegeofcoruscant" - ] + "missile": ["discordmissiles"], + "modification": ["contingencyprotocol-siegeofcoruscant"], + "configuration": ["strutlockoverride-siegeofcoruscant"] } }, "haorchallprototype-siegeofcoruscant": { "points": 2, "upgrades": { - "missile": [ - "ionmissiles" - ], - "modification": [ - "contingencyprotocol-siegeofcoruscant" - ], - "configuration": [ - "strutlockoverride-siegeofcoruscant" - ] + "missile": ["ionmissiles"], + "modification": ["contingencyprotocol-siegeofcoruscant"], + "configuration": ["strutlockoverride-siegeofcoruscant"] } }, "countdooku-siegeofcoruscant": { "points": 6, "upgrades": { - "force-power": [ - "malice", - "roilinganger-siegeofcoruscant" - ], - "title": [ - "scimitar" - ] + "force-power": ["malice", "roilinganger-siegeofcoruscant"], + "title": ["scimitar"] } }, "dbs32c-siegeofcoruscant": { "points": 3, "upgrades": { - "torpedo": [ - "plasmatorpedoes" - ], - "modification": [ - "contingencyprotocol-siegeofcoruscant" - ], - "configuration": [ - "strutlockoverride-siegeofcoruscant" - ] + "torpedo": ["plasmatorpedoes"], + "modification": ["contingencyprotocol-siegeofcoruscant"], + "configuration": ["strutlockoverride-siegeofcoruscant"] } }, "dbs404-siegeofcoruscant": { "points": 3, "upgrades": { - "torpedo": [ - "advprotontorpedoes" - ], - "modification": [ - "contingencyprotocol-siegeofcoruscant" - ], - "configuration": [ - "strutlockoverride-siegeofcoruscant" - ] + "torpedo": ["advprotontorpedoes"], + "modification": ["contingencyprotocol-siegeofcoruscant"], + "configuration": ["strutlockoverride-siegeofcoruscant"] } }, "baktoidprototype-siegeofcoruscant": { "points": 3, "upgrades": { - "missile": [ - "homingmissiles" - ], - "modification": [ - "contingencyprotocol-siegeofcoruscant" - ], - "configuration": [ - "strutlockoverride-siegeofcoruscant" - ] + "missile": ["homingmissiles"], + "modification": ["contingencyprotocol-siegeofcoruscant"], + "configuration": ["strutlockoverride-siegeofcoruscant"] } }, "dis347-siegeofcoruscant": { "points": 3, "upgrades": { - "talent": [ - "marksmanship" - ], - "modification": [ - "afterburners", - "contingencyprotocol-siegeofcoruscant" - ] + "talent": ["marksmanship"], + "modification": ["afterburners", "contingencyprotocol-siegeofcoruscant"] } }, "dist81-siegeofcoruscant": { "points": 4, "upgrades": { - "talent": [ - "outmaneuver" - ], - "modification": [ - "afterburners", - "contingencyprotocol-siegeofcoruscant" - ] + "talent": ["outmaneuver"], + "modification": ["afterburners", "contingencyprotocol-siegeofcoruscant"] } }, "phlacarphoccprototype-siegeofcoruscant": { diff --git a/lib/data/standard-legal.json b/lib/data/standard-ships.json similarity index 100% rename from lib/data/standard-legal.json rename to lib/data/standard-ships.json diff --git a/lib/get-value.ts b/lib/get-value.ts index 8b946ce7..f80e7b84 100644 --- a/lib/get-value.ts +++ b/lib/get-value.ts @@ -1,5 +1,5 @@ import data from './data/display-values.json'; -import legal from './data/standard-legal.json'; +import standard from './data/standard-ships.json'; import { XWSFaction } from './types'; export type Factions = keyof typeof data.faction; @@ -26,4 +26,20 @@ export const getUpgradeName = (xws: string): string | null => (data.upgrades as any)[xws] || null; export const getStandardShips = (faction: Factions) => - legal[faction].ships as Ships[]; + standard[faction].ships as Ships[]; + +export const getFactionByShip = (ship: Ships) => { + let faction: XWSFaction = 'rebelalliance'; // Init with something + + for (const key in standard) { + const current = key as XWSFaction; + const hasShip = standard[current].ships.includes(ship); + + if (hasShip) { + faction = current; + break; + } + } + + return faction; +}; diff --git a/lib/stats/details/composition.ts b/lib/stats/details/composition.ts new file mode 100644 index 00000000..0eff1023 --- /dev/null +++ b/lib/stats/details/composition.ts @@ -0,0 +1,380 @@ +import { getFactionByShip, type Ships } from '@/lib/get-value'; +import type { + GameRecord, + SquadData, + XWSFaction, + XWSSquad, + XWSUpgrades, +} from '@/lib/types'; +import { fromDate, toMonth } from '@/lib/utils/date.utils'; +import { + average, + deviation, + percentile, + round, + winrate, +} from '@/lib/utils/math.utils'; + +// Types +// --------------- +export interface SquadCompositionData { + /** + * Composition id (ships separated by ".") + */ + id: string; + faction: XWSFaction; + count: number; + record: GameRecord; + percentiles: number[]; + + pilot: { + [id: string]: { + ship: Ships; + count: number; + upgrades: XWSUpgrades[]; + record: GameRecord; + percentiles: number[]; + }; + }; + + squads: { + /** + * Pilot ids separated by "." + */ + id: string; + player: string; + date: string; + xws: XWSSquad; + percentile: number; + record: GameRecord; + }[]; +} + +export interface SquadCompositionStats { + id: string; + faction: XWSFaction; + ships: Ships[]; + count: number; + frequency: number; + winrate: number | null; + percentile: number; + deviation: number; + + trend: { + /** + * Date format YYYY-MM + */ + date: string; + count: number; + percentile: number; + }[]; + + /** + * Grouped by pilots + */ + squads: { + [pilots: string]: { + items: { + xws: XWSSquad; + date: string; + player: string; + }[]; + frequency: number; + winrate: number | null; + percentile: number; + deviation: number; + }; + }; + + pilot: { + [id: string]: { + ship: Ships; + upgrades: { + id: string; + list: XWSUpgrades; + count: number; + percentile: number; + }[]; + count: number; + frequency: number; + winrate: number | null; + percentile: number; + deviation: number; + }; + }; +} + +// Helpers +// --------------- +const createPilotsId = (xws: XWSSquad) => { + const pilots = [...xws.pilots]; + pilots.sort((a, b) => { + if (a.ship < b.ship) { + return -1; + } + if (a.ship > b.ship) { + return 1; + } + if (a.id < b.id) { + return -1; + } + if (a.id > b.id) { + return 1; + } + return 0; + }); + + return pilots.map(({ id }) => id).join('.'); +}; + +const isComposition = (id: string, xws: XWSSquad) => { + const ships = xws.pilots.map(p => p.ship); + ships.sort(); + return id === ships.join('.'); +}; + +const createTrends = (squads: SquadCompositionData['squads']) => { + const trends: { [month: string]: { count: number; percentiles: number[] } } = + {}; + + squads.forEach(squad => { + const date = toMonth(squad.date); + + const item = trends[date] || { count: 0, percentiles: [] }; + item.count += 1; + item.percentiles.push(squad.percentile); + + trends[date] = item; + }); + + const result = Object.entries(trends).map( + ([date, { count, percentiles }]) => ({ + date, + count, + percentile: average(percentiles, 4), + }) + ); + + result.sort( + (a, b) => + new Date(fromDate(`${a.date}-01`)).getTime() - + new Date(fromDate(`${b.date}-01`)).getTime() + ); + + return result; +}; + +const groupSquads = (squads: SquadCompositionData['squads']) => { + const data: { + [id: string]: { + percentiles: number[]; + record: GameRecord; + items: { xws: XWSSquad; date: string; player: string }[]; + }; + } = {}; + const groups: SquadCompositionStats['squads'] = {}; + + squads.forEach(squad => { + const current = data[squad.id] || { + record: { + wins: 0, + ties: 0, + losses: 0, + }, + percentiles: [], + items: [], + }; + + current.record.wins += squad.record.wins; + current.record.ties += squad.record.ties; + current.record.losses += squad.record.losses; + current.percentiles.push(squad.percentile); + current.items.push({ + xws: squad.xws, + date: squad.date, + player: squad.player, + }); + + data[squad.id] = current; + }); + + Object.keys(data).forEach(id => { + const current = data[id]; + current.items.sort( + (a, b) => fromDate(b.date).getTime() - fromDate(a.date).getTime() + ); + + groups[id] = { + items: current.items, + frequency: round(current.items.length / squads.length, 4), + winrate: winrate([current.record]), + percentile: average(current.percentiles, 4), + deviation: deviation(current.percentiles, 4), + }; + }); + + return groups; +}; + +/** + * Group upgrades of one pilot if the upgrades + * are exactly the same. + */ +const groupUpgrades = (value: { + upgrades: XWSUpgrades[]; + percentiles: number[]; +}) => { + const getId = (us: XWSUpgrades) => { + const val = Object.values(us).flat(); + val.sort(); + return val.join('.'); + }; + + const data: { + [id: string]: { + count: number; + list: XWSUpgrades; + percentiles: number[]; + }; + } = {}; + const groups: { + id: string; + list: XWSUpgrades; + count: number; + percentile: number; + }[] = []; + + value.upgrades.forEach((upgrades, idx) => { + const id = getId(upgrades); + const current = data[id] || { + list: upgrades, + count: 0, + percentiles: [], + }; + + // Upgrades and percentile have same index + current.percentiles.push(value.percentiles[idx]); + current.count += 1; + + data[id] = current; + }); + + // map -> array + Object.keys(data).forEach(id => { + groups.push({ + id, + count: data[id].count, + percentile: average(data[id].percentiles, 4), + list: data[id].list, + }); + }); + + groups.sort((a, b) => b.percentile - a.percentile); + + return groups; +}; + +// Module +// --------------- +export const compositionDetails = ( + id: string, + input: { date: string; squads: SquadData[] }[] +) => { + const stats: SquadCompositionData = { + id, + // Get first ship to derive faction + faction: getFactionByShip(id.split('.')[0] as Ships), + squads: [], + count: 0, + record: { wins: 0, ties: 0, losses: 0 }, + percentiles: [], + pilot: {}, + }; + + let squadsInFaction = 0; + + input.forEach(({ date, squads }) => { + const total = squads.length; + + squads.forEach(current => { + if (!current.xws) return; + + if (stats.faction === current.xws.faction) { + squadsInFaction += 1; + } + + if (!isComposition(id, current.xws)) return; + + const pct = percentile( + current.rank.elimination ?? current.rank.swiss, + total + ); + + // Overall stats + stats.count += 1; + stats.record.wins += current.record.wins; + stats.record.ties += current.record.ties; + stats.record.losses += current.record.losses; + stats.percentiles.push(pct); + + stats.squads.push({ + id: createPilotsId(current.xws), + player: current.player, + xws: current.xws, + date, + record: current.record, + percentile: pct, + }); + + // Stats based on pilot + current.xws.pilots.forEach(({ id: pid, ship, upgrades }) => { + const pilot = stats.pilot[pid] || { + ship, + count: 0, + upgrades: [], + record: { wins: 0, ties: 0, losses: 0 }, + percentiles: [], + }; + + pilot.count += 1; + pilot.upgrades.push(upgrades); + pilot.record.wins += current.record.wins; + pilot.record.ties += current.record.ties; + pilot.record.losses += current.record.losses; + pilot.percentiles.push(pct); + + stats.pilot[pid] = pilot; + }); + }); + }); + + // Overall + const result: SquadCompositionStats = { + id: stats.id, + faction: stats.faction, + ships: stats.id.split('.') as Ships[], + count: stats.count, + frequency: round(stats.count / squadsInFaction, 4), + winrate: winrate([stats.record]), + percentile: average(stats.percentiles, 4), + deviation: deviation(stats.percentiles, 4), + trend: createTrends(stats.squads), + squads: groupSquads(stats.squads), + pilot: {}, // Filled below + }; + + // Pilots + Object.entries(stats.pilot).forEach(([pid, pilot]) => { + result.pilot[pid] = { + ship: pilot.ship, + upgrades: groupUpgrades(pilot), + count: pilot.count, + frequency: round(pilot.count / stats.count, 4), + winrate: winrate([pilot.record]), + percentile: average(pilot.percentiles, 4), + deviation: deviation(pilot.percentiles, 4), + }; + }); + + return result; +}; diff --git a/lib/utils/date.utils.ts b/lib/utils/date.utils.ts index 6d62dbdc..349bf86a 100644 --- a/lib/utils/date.utils.ts +++ b/lib/utils/date.utils.ts @@ -48,6 +48,12 @@ export const toDate = (from: Date, to?: Date) => { return to ? `${start}/${dayjs(to).format('YYYY-MM-DD')}` : start; }; +/** + * Transforms a string (date) to 'YYYY-MM'. Can be used to group data by month. + */ +export const toMonth = (val: string) => + dayjs(val).startOf('month').format('YYYY-MM'); + /** * Formats date to a human readable format. */ @@ -61,3 +67,9 @@ export const formatDate = (date: Date) => { year: 'numeric', }); }; + +/** + * Formats motn date ('YYYY-MM') to a human readable format. + */ +export const formatMonth = (val: string) => + dayjs(val, 'YYYY-MM').format('MMM YY'); diff --git a/lib/xws.ts b/lib/xws.ts index 5d38b01d..68a32cff 100644 --- a/lib/xws.ts +++ b/lib/xws.ts @@ -1,6 +1,7 @@ -import type { XWSSquad } from './types'; +import type { XWSSquad, XWSUpgrades } from './types'; import SL_PILOTS from './data/standard-loadout-pilots.json'; import { getPointsByName } from './yasb'; +import { getUpgradeName } from './get-value'; // LBN has some error and unnormalized in pilot ids. const PILOT_ID_MAP = { @@ -89,3 +90,11 @@ export const getBuilderLink = (xws: XWSSquad | null) => // Remove `print` from lbn to show the builder instead xws?.vendor?.lbn?.link.replace('print', '') || null; + +export const upgradesToList = (upgrades: XWSUpgrades) => + (Object.entries(upgrades) as [keyof XWSUpgrades, string[]][]) + .map(([_, list]) => list.map(name => getUpgradeName(name) || name)) + .flat() + .join(', '); + +export const isStandardized = (pilot: string) => pilot in SL_PILOTS; diff --git a/next.config.js b/next.config.js index 2132d215..2b5feab3 100644 --- a/next.config.js +++ b/next.config.js @@ -43,15 +43,9 @@ const config = { remotePatterns: [ { protocol: 'https', - hostname: 'squadbuilder.fantasyflightgames.com', + hostname: 'infinitearenas.com', port: '', - pathname: '/ship_types/**', - }, - { - protocol: 'https', - hostname: 'squadbuilder.fantasyflightgames.com', - port: '', - pathname: '/factions/**', + pathname: '/xw2/images/**', }, ], }, diff --git a/package.json b/package.json index 9eeeee97..357af5af 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "next build", "start": "next start", "lint": "next lint", + "format": "prettier . --write", "typecheck": "tsc --noEmit", "test": "jest", "clean": "rm -rf .next", @@ -21,6 +22,7 @@ "@nivo/core": "0.80.0", "@nivo/line": "0.80.0", "@nivo/pie": "0.80.0", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-dialog": "1.0.4", "@radix-ui/react-switch": "^1.0.3", "@vercel/analytics": "1.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ca191b5..981acf0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: '@nivo/pie': specifier: 0.80.0 version: 0.80.0(@nivo/core@0.80.0)(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-accordion': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-dialog': specifier: 1.0.4 version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) @@ -1135,6 +1138,87 @@ packages: '@babel/runtime': 7.20.7 dev: false + /@radix-ui/react-accordion@1.1.2(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.20.7 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collapsible': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@types/react': 18.2.15 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.20.7 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@types/react': 18.2.15 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.20.7 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.15)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.15)(react@18.2.0) + '@types/react': 18.2.15 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.15)(react@18.2.0): resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} peerDependencies: @@ -1197,6 +1281,20 @@ packages: react-remove-scroll: 2.5.5(@types/react@18.2.15)(react@18.2.0) dev: false + /@radix-ui/react-direction@1.0.1(@types/react@18.2.15)(react@18.2.0): + resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.20.7 + '@types/react': 18.2.15 + react: 18.2.0 + dev: false + /@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==} peerDependencies: @@ -3109,7 +3207,7 @@ packages: dev: true /event-stream@3.3.4: - resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + resolution: {integrity: sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=} dependencies: duplexer: 0.1.2 from: 0.1.7 @@ -4913,7 +5011,7 @@ packages: dev: true /pause-stream@0.0.11: - resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + resolution: {integrity: sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=} dependencies: through: 2.3.8 dev: true diff --git a/postcss.config.js b/postcss.config.js index 33ad091d..12a703d9 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/scripts/update-xwing-data.mjs b/scripts/update-xwing-data.mjs index 97254623..bce308bc 100644 --- a/scripts/update-xwing-data.mjs +++ b/scripts/update-xwing-data.mjs @@ -205,14 +205,14 @@ const normalization = {}; // Standard Legal // --------------- -const standard = {}; +const ships = {}; read(manifest.factions[0]).forEach(({ xws: factionId, name, icon }) => { display.faction[factionId] = { name, icon, }; - standard[factionId] = { + ships[factionId] = { ships: [], }; @@ -237,8 +237,8 @@ read(manifest.factions[0]).forEach(({ xws: factionId, name, icon }) => { }; } - if (pilot.standard && !standard[factionId].ships.includes(ship.id)) { - standard[factionId].ships.push(ship.id); + if (pilot.standard && !ships[factionId].ships.includes(ship.id)) { + ships[factionId].ships.push(ship.id); } }); }); @@ -261,6 +261,6 @@ await fs.outputJson(`${TARGET}/display-values.json`, display, { spaces: 2 }); await fs.outputJson(`${TARGET}/standard-loadout-pilots.json`, normalization, { spaces: 2, }); -await fs.outputJson(`${TARGET}/standard-legal.json`, standard, { +await fs.outputJson(`${TARGET}/standard-ships.json`, ships, { spaces: 2, }); diff --git a/tailwind.config.js b/tailwind.config.js index a2f90b01..e076d653 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -47,6 +47,20 @@ module.exports = { '0px 1px 3px 0px rgba(0 0 0 / 0.02), 0px 0px 0px 1px rgba(27 31 35 / 0.15)', card: '0 1px 3px 0 #c9cfd8, 0 1px 2px -1px #c9cfd8, 0px 0px 0px 1px #e2e6eb', }, + keyframes: { + 'accordion-down': { + from: { height: 0 }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: 0 }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, }, data: { active: 'state~="active"', diff --git a/ui/accordion.tsx b/ui/accordion.tsx new file mode 100644 index 00000000..a00d5ad3 --- /dev/null +++ b/ui/accordion.tsx @@ -0,0 +1,78 @@ +'use client'; + +import * as React from 'react'; +import * as Primitive from '@radix-ui/react-accordion'; + +import { cn } from '@/lib/utils'; +import { ChevronDown } from './icons'; + +// Accordion.Item +// --------------- +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +// Accordtion.Trigger +// --------------- +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + 'outline-none focus-visible:bg-primary-200/50' + )} + {...props} + > +
{children}
+ +
+
+)); +AccordionTrigger.displayName = Primitive.Trigger.displayName; + +// Accordion.Content +// --------------- +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = Primitive.Content.displayName; + +// Accordtion +// --------------- +export const Accordion = ( + props: React.ComponentProps +) => ; + +Accordion.Item = AccordionItem; +Accordion.Trigger = AccordionTrigger; +Accordion.Content = AccordionContent; diff --git a/ui/badge.tsx b/ui/badge.tsx new file mode 100644 index 00000000..9a0a52ad --- /dev/null +++ b/ui/badge.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react'; +import { VariantProps, cva } from 'class-variance-authority'; +import { cn } from '@/lib/utils/classname.utils'; + +// Styles +// --------------- +const styles = cva('inline-flex items-center leading-none justify-center ', { + variants: { + variant: { + default: 'bg-primary-800 text-primary-50', + light: 'bg-primary-100 text-primary-800', + neutral: 'bg-secondary-100/75 text-secondary-600', + }, + size: { + default: 'text-sm font-medium py-1 px-2 rounded-lg', + small: 'text-xs py-0.5 px-1.5 rounded-md', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, +}); + +// Props +// --------------- +export interface BadgeProps extends VariantProps { + className?: string; + children?: ReactNode; +} + +// Component +// --------------- +export const Badge = ({ className, variant, size, children }: BadgeProps) => ( +
{children}
+); diff --git a/ui/button.tsx b/ui/button.tsx index 4296f5ef..851979a7 100644 --- a/ui/button.tsx +++ b/ui/button.tsx @@ -5,7 +5,7 @@ import { forwardRef } from 'react'; // --------------- const styles = cva( [ - 'rounded-lg transition-all focus:outline-none focus-visible:ring', + 'transition-all focus:outline-none focus-visible:ring', 'disabled:cursor-not-allowed disabled:border-primary-300/25 disabled:bg-primary-300/50', ], { @@ -33,9 +33,10 @@ const styles = cva( }, size: { inherit: '', // inherit whatever is there - regular: 'text-sm px-5 py-2.5 shadow-sm', - large: 'text-lg px-6 py-3 shadow', - huge: 'text-xl px-12 py-5 shadow', + small: 'rounded text-xs px-2 py-1', + regular: 'rounded-lg text-sm px-5 py-2.5 shadow-sm', + large: 'rounded-lg text-lg px-6 py-3 shadow', + huge: 'rounded-lg text-xl px-12 py-5 shadow', }, }, defaultVariants: { diff --git a/ui/card.tsx b/ui/card.tsx index 57670909..54b35cb0 100644 --- a/ui/card.tsx +++ b/ui/card.tsx @@ -7,24 +7,39 @@ import { cn } from '@/lib/utils/classname.utils'; // --------------- const styles = { card: cva( - ['flex h-full w-full flex-col items-stretch gap-4', 'rounded-lg bg-white'], + ['flex w-full flex-col items-stretch gap-4', 'rounded-lg bg-white'], { variants: { elevation: { - default: ['shadow-card'], - lightest: ['shadow-sm'], - light: ['shadow'], + default: 'shadow-card', + lightest: 'shadow-sm', + light: 'shadow', }, inset: { - default: ['px-3 pt-3 pb-2'], + default: 'px-3 pt-3 pb-2', + headless: 'px-3 pt-4 pb-2', // No title/card.header + list: 'px-0 pt-3 pb-2', // for when using a list + }, + size: { + stretch: 'h-full', + fit: 'h-fit', }, }, defaultVariants: { elevation: 'default', inset: 'default', + size: 'stretch', }, } ), + body: cva('flex-1', { + variants: { + variant: { + enumeration: + 'divide-y divide-secondary-100 border-t border-secondary-100', + }, + }, + }), }; // Card.Title @@ -49,12 +64,13 @@ const CardHeader = ({ children }: CardHeaderProps) => ( // Card.Body // --------------- -export interface CardBodyProps { +export interface CardBodyProps extends VariantProps { children: React.ReactNode; + className?: string; } -const CardBody = ({ children }: CardBodyProps) => ( -
{children}
+const CardBody = ({ variant, children, className }: CardBodyProps) => ( +
{children}
); // Card.Footer @@ -80,11 +96,14 @@ const CardMenu = ({ children }: CardMenuProps) => ( // Card.Action // --------------- export interface CardActionsProps { + className?: string; children: React.ReactNode; } -const CardActions = ({ children }: CardActionsProps) => ( -
{children}
+const CardActions = ({ className, children }: CardActionsProps) => ( +
+ {children} +
); // Card @@ -97,15 +116,16 @@ export interface CardProps } export const Card = ({ - elevation: variant, + elevation, inset, + size, className, children, ...props }: CardProps) => (
{children}
diff --git a/ui/collapsible.tsx b/ui/collapsible.tsx index 5bf4576a..a06776a9 100644 --- a/ui/collapsible.tsx +++ b/ui/collapsible.tsx @@ -36,6 +36,7 @@ export interface CollapsibleProps { maxHeight: number; defaultCollapsed?: boolean; scrollOffset?: number; + disabled?: boolean; children: React.ReactElement; } @@ -45,6 +46,7 @@ export const Collapsible = ({ defaultCollapsed = true, scrollOffset = 150, maxHeight, + disabled, children, }: CollapsibleProps) => { const wrapperRef = useRef(null); @@ -66,9 +68,9 @@ export const Collapsible = ({ /** * Do not wrap into collapsible if the element is not - * larger than given max height. + * larger than given max height ... or if it is disabled. */ - if (height <= maxHeight) { + if (disabled || height <= maxHeight) { return <>{child}; } diff --git a/ui/detail.tsx b/ui/detail.tsx new file mode 100644 index 00000000..68f170e1 --- /dev/null +++ b/ui/detail.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from 'react'; +import { type VariantProps, cva } from 'class-variance-authority'; +import { cn } from '@/lib/utils/classname.utils'; + +// Styles +// --------------- +const styles = { + container: cva('', { + variants: { + align: { + left: 'flex items-center justify-between gap-2', + }, + variant: { + default: '', + secondary: '', + }, + size: { + default: '', + large: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }), + label: cva('text-sm font-medium leading-none', { + variants: { + variant: { + default: 'text-primary-500', + secondary: 'text-secondary-400', + }, + size: { + default: '', + large: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }), + value: cva('', { + variants: { + variant: { + default: 'text-secondary-700', + secondary: 'text-secondary-950', + }, + size: { + default: 'text-lg font-medium', + large: 'text-2xl font-medium', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }), +}; + +// Props +// --------------- +export interface DetailProps extends VariantProps { + className?: string; + label: ReactNode; + value: ReactNode; +} + +// Component +// --------------- +export const Detail = ({ + label, + value, + variant, + size, + align, + className, +}: DetailProps) => ( +
+
+ {label} +
+
+ {value} +
+
+); diff --git a/ui/dialog.tsx b/ui/dialog.tsx index 060de307..70d115e7 100644 --- a/ui/dialog.tsx +++ b/ui/dialog.tsx @@ -33,7 +33,7 @@ const DialogOverlay = forwardRef< >(({ className, children, ...props }, ref) => ( ( /> ); + +export const Check = ({ className, ...props }: IconProps) => ( + + + +); + +export const ChevronDown = ({ className, ...props }: IconProps) => ( + + + +); + export const Close = ({ className, ...props }: IconProps) => ( ( ); +export const Copy = ({ className, ...props }: IconProps) => ( + + + +); + export const Download = ({ className, ...props }: IconProps) => ( ( ); +export const Folder = ({ className, ...props }: IconProps) => ( + + + +); + export const Info = ({ className, ...props }: IconProps) => ( , 'src' | 'alt'> { + pilot: string; + type?: 'card' | 'art'; + alt?: string; +} + +export const PilotImage = ({ + pilot, + type = 'card', + alt, + ...props +}: PilotImageProps) => ( + +); diff --git a/ui/ship-icon.tsx b/ui/ship-icon.tsx index d2c49488..28738407 100644 --- a/ui/ship-icon.tsx +++ b/ui/ship-icon.tsx @@ -20,7 +20,7 @@ export const ShipIcon = ({ ship, className, ...props }: ShipIconProps) => ( {(icons as any)[ship] || ship} diff --git a/ui/squad-list.tsx b/ui/squad-list.tsx new file mode 100644 index 00000000..43fc720b --- /dev/null +++ b/ui/squad-list.tsx @@ -0,0 +1,79 @@ +import type { SquadData } from '@/lib/types'; + +import { Card } from './card'; +import { CopyButton } from './copy-button'; +import { Archive } from './icons'; +import { Link } from './link'; +import { Squad } from './squad'; +import { Tiles } from './tiles'; + +// Helpers +// --------------- +const getVendorName = (link: string) => { + if (link.includes('yasb.app')) { + return 'YASB'; + } + if (link.includes('launchbaynext')) { + return 'LBN'; + } + return 'Builder'; +}; + +const NoSquadInfo = () => ( +
+
+ +
No list submitted.
+
+
+); + +// Props +// --------------- +export interface SquadListProps { + squads: SquadData[]; +} + +// Component +// --------------- +export const SquadList = ({ squads }: SquadListProps) => { + return ( + + {squads.map(squad => ( + + + {squad.xws ? ( + + ) : squad.raw ? ( +
+ {squad.raw} +
+ ) : ( + + )} +
+ +
+
+ #{squad.rank.elimination || squad.rank.swiss}: {squad.player} +
+ {squad.url ? ( + + View in {getVendorName(squad.url)} + + ) : squad.xws ? ( + + Copy XWS + + ) : null} +
+
+
+ ))} +
+ ); +}; diff --git a/ui/squad.tsx b/ui/squad.tsx index af11c4fe..cc49e5af 100644 --- a/ui/squad.tsx +++ b/ui/squad.tsx @@ -1,25 +1,65 @@ -import { getPilotName, getShipName, getUpgradeName } from '@/lib/get-value'; -import type { XWSSquad, XWSUpgrades } from '@/lib/types'; +import { VariantProps, cva } from 'class-variance-authority'; -const upgradesToList = (upgrades: XWSUpgrades) => - (Object.entries(upgrades) as [keyof XWSUpgrades, string[]][]) - .map(([_, list]) => list.map(name => getUpgradeName(name) || name)) - .flat() - .join(', '); +import { cn } from '@/lib/utils/classname.utils'; +import { getPilotName, getShipName } from '@/lib/get-value'; +import type { XWSSquad } from '@/lib/types'; +import { upgradesToList } from '@/lib/xws'; -export interface SquadProps { +// Helper +// --------------- +const styles = { + container: cva('flex flex-col', { + variants: { + variant: { + narrow: 'gap-2.5', + default: 'gap-4', + }, + size: { + default: '', + small: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }), + pilot: cva('flex items-center font-semibold', { + variants: { + variant: { + narrow: 'leading-none', + default: 'pb-1', + }, + size: { + default: '', + small: 'text-sm', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }), +}; + +// Props +// --------------- +export interface SquadProps extends VariantProps { + className?: string; xws: XWSSquad; } -export const Squad = ({ xws }: SquadProps) => { +// Component +// --------------- +export const Squad = ({ variant, size, className, xws }: SquadProps) => { const { pilots } = xws; return ( -
+
{pilots.map(({ id, ship, upgrades }, idx) => (
{getPilotName(id) || id} diff --git a/ui/stats/composition-stats.tsx b/ui/stats/composition-stats.tsx deleted file mode 100644 index 4025b53c..00000000 --- a/ui/stats/composition-stats.tsx +++ /dev/null @@ -1,149 +0,0 @@ -'use client'; - -import { Fragment, useState } from 'react'; - -import type { Ships } from '@/lib/get-value'; -import { - Card, - Collapsible, - FactionIcon, - FactionSelection, - Select, - ShipIcon, - Table, -} from '@/ui'; -import { toPercentage } from '@/lib/utils/math.utils'; -import type { GameRecord, XWSFaction } from '@/lib/types'; - -interface CompositionStatsType { - ships: Ships[]; - faction: XWSFaction; - count: number; - record: GameRecord; - frequency: number; - winrate: number | null; - percentile: number; - deviation: number; - score: number; -} - -// Props -// --------------- -export interface CompositionStatsProps { - value: { [id: string]: CompositionStatsType }; -} - -// Component -// --------------- -export const CompositionStats = ({ value }: CompositionStatsProps) => { - const [faction, setFaction] = useState('all'); - const [sort, setSort] = useState< - 'percentile' | 'deviation' | 'winrate' | 'frequency' | 'count' | 'score' - >('percentile'); - - const data = - faction === 'all' - ? (Object.entries(value) as [string, CompositionStatsType][]) - : Object.entries(value).filter( - ([_, stat]: [string, CompositionStatsType]) => - stat.faction === faction - ); - - data.sort(([, a], [, b]) => { - const result = (b[sort] || 0) - (a[sort] || 0); - - // Secondary sort by percentile (or deviation if sorted by percentile already) - return result !== 0 - ? result - : sort === 'percentile' - ? b.deviation - a.deviation - : b.percentile - a.percentile; - }); - - return ( - - - Compositions - - - - - - - - - {data.map(([id, stat]) => ( - - - {stat.ships.map((ship, idx) => ( - - ))} - - - - - - {toPercentage(stat.percentile)} - - - {stat.deviation === 0 ? '-' : toPercentage(stat.deviation)} - - - {stat.winrate !== null ? toPercentage(stat.winrate) : '-'} - - - {toPercentage(stat.frequency)} - - {stat.count} - {stat.score} - - ))} -
-
-
-
- ); -}; diff --git a/ui/stats/composition-stats/composition-filter.tsx b/ui/stats/composition-stats/composition-filter.tsx new file mode 100644 index 00000000..b92e6cc1 --- /dev/null +++ b/ui/stats/composition-stats/composition-filter.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { FactionSelection, Select } from '@/ui'; +import { useCompositionFilter } from './context'; + +// Component +// --------------- +export const CompositionFilter = () => { + const { faction, setFaction, sort, setSort } = useCompositionFilter(); + + return ( + <> + + + + ); +}; diff --git a/ui/stats/composition-stats/composition-stats.tsx b/ui/stats/composition-stats/composition-stats.tsx new file mode 100644 index 00000000..4150a21c --- /dev/null +++ b/ui/stats/composition-stats/composition-stats.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { Card } from '@/ui'; + +import { CompositionTable } from './composition-table'; +import { CompositionFilterProvider, useCompositionFilter } from './context'; +import type { CompositionStatsType } from './types'; +import { CompositionFilter } from './composition-filter'; + +// Props +// --------------- +export interface CompositionStatsProps { + value: { [id: string]: CompositionStatsType }; +} + +// Component +// --------------- +export const CompositionStats = ({ value }: CompositionStatsProps) => ( + + + + Compositions + + + + + + + + + +); diff --git a/ui/stats/composition-stats/composition-table.tsx b/ui/stats/composition-stats/composition-table.tsx new file mode 100644 index 00000000..d3228fc9 --- /dev/null +++ b/ui/stats/composition-stats/composition-table.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { Fragment } from 'react'; + +import { toPercentage } from '@/lib/utils'; +import { Collapsible, FactionIcon, Link, ShipIcon, Table } from '@/ui'; +import { Folder } from '@/ui/icons'; + +import { useCompositionFilter } from './context'; +import type { CompositionStatsType } from './types'; + +// Props +// --------------- +export interface CompositionTableProps { + value: { [id: string]: CompositionStatsType }; + collapsible?: boolean; +} + +// Components +// --------------- +export const CompositionTable = ({ + value, + collapsible = true, +}: CompositionTableProps) => { + const { faction = 'all', sort = 'percentile' } = useCompositionFilter(); + + const data = + faction === 'all' + ? (Object.entries(value) as [string, CompositionStatsType][]) + : Object.entries(value).filter( + ([_, stat]: [string, CompositionStatsType]) => + stat.faction === faction + ); + + data.sort(([, a], [, b]) => { + const result = (b[sort] || 0) - (a[sort] || 0); + + // Secondary sort by percentile (or deviation if sorted by percentile already) + return result !== 0 + ? result + : sort === 'percentile' + ? b.deviation - a.deviation + : b.percentile - a.percentile; + }); + + return ( + + + {data.map(([id, stat]) => ( + + + {stat.ships.map((ship, idx) => ( + + ))} + + + + + + {toPercentage(stat.percentile)} + + + {stat.deviation === 0 ? '-' : toPercentage(stat.deviation)} + + + {stat.winrate !== null ? toPercentage(stat.winrate) : '-'} + + + {toPercentage(stat.frequency)} + + {stat.count} + {stat.score} + + + + + + + ))} +
+
+ ); +}; diff --git a/ui/stats/composition-stats/context.tsx b/ui/stats/composition-stats/context.tsx new file mode 100644 index 00000000..f7a185b6 --- /dev/null +++ b/ui/stats/composition-stats/context.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { createContext, ReactNode, useContext, useState } from 'react'; +import { Factions } from '@/lib/get-value'; + +export type FactionOptions = 'all' | Factions; +export type SortOptions = + | 'percentile' + | 'deviation' + | 'winrate' + | 'frequency' + | 'count' + | 'score'; + +const Context = createContext<{ + faction: FactionOptions; + setFaction: (faction: FactionOptions) => void; + sort: SortOptions; + setSort: (value: SortOptions) => void; +} | null>(null); + +export interface FilterProviderProps { + children: ReactNode; +} + +export const CompositionFilterProvider = ({ + children, +}: FilterProviderProps) => { + const [faction, setFaction] = useState('all'); + const [sort, setSort] = useState('percentile'); + + return ( + + {children} + + ); +}; + +export const useCompositionFilter = () => { + const context = useContext(Context); + + if (context === null) { + throw new Error( + '"useCompositionFilter" must be used within a ' + ); + } + + return context; +}; diff --git a/ui/stats/composition-stats/index.ts b/ui/stats/composition-stats/index.ts new file mode 100644 index 00000000..ea7a9332 --- /dev/null +++ b/ui/stats/composition-stats/index.ts @@ -0,0 +1,5 @@ +export * from './composition-filter'; +export * from './composition-stats'; +export * from './composition-table'; +export * from './context'; +export * from './types'; diff --git a/ui/stats/composition-stats/types.ts b/ui/stats/composition-stats/types.ts new file mode 100644 index 00000000..6359b385 --- /dev/null +++ b/ui/stats/composition-stats/types.ts @@ -0,0 +1,14 @@ +import type { Ships } from '@/lib/get-value'; +import type { XWSFaction, GameRecord } from '@/lib/types'; + +export interface CompositionStatsType { + ships: Ships[]; + faction: XWSFaction; + count: number; + record: GameRecord; + frequency: number; + winrate: number | null; + percentile: number; + deviation: number; + score: number; +} diff --git a/ui/stats/filter.tsx b/ui/stats/filter.tsx index eba614e2..1bc1773d 100644 --- a/ui/stats/filter.tsx +++ b/ui/stats/filter.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTransition } from 'react'; +import { type ReactNode, useTransition } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { DateSelection, Spinner, Switch } from '@/ui'; @@ -8,13 +8,14 @@ import { DateSelection, Spinner, Switch } from '@/ui'; // Props // --------------- export interface FilterProps { + children?: ReactNode; dateRange: string; smallSamples: boolean; } // Component // --------------- -export const Filter = ({ dateRange, smallSamples }: FilterProps) => { +export const Filter = ({ children, dateRange, smallSamples }: FilterProps) => { const { replace } = useRouter(); const pathname = usePathname(); const [pending, startTransition] = useTransition(); @@ -46,7 +47,7 @@ export const Filter = ({ dateRange, smallSamples }: FilterProps) => { }; return ( -
+
{pending ? : null} { onChange={e => handleChange(['dateRange', e.target.value])} disabled={pending} /> + {children}
); }; diff --git a/ui/table.tsx b/ui/table.tsx index b1fbb7c4..5b509fd0 100644 --- a/ui/table.tsx +++ b/ui/table.tsx @@ -60,7 +60,7 @@ export const TableCell = ({ className, children, }: TableCellProps) => ( -
+
{children}
); diff --git a/ui/timeline.tsx b/ui/timeline.tsx new file mode 100644 index 00000000..dce66c96 --- /dev/null +++ b/ui/timeline.tsx @@ -0,0 +1,68 @@ +import { ReactNode } from 'react'; + +// Timeline.Item +// --------------- +export interface TimelineItemProps { + children?: ReactNode; +} + +const TimelineItem = ({ children }: TimelineItemProps) => ( +
{children}
+); + +// Timeline.Header +// --------------- +export interface TimelineHeaderProps { + children?: ReactNode; +} + +const TimelineHeader = ({ children }: TimelineHeaderProps) => ( +
+
+ {children} +
+); + +// Timeline.Caption +// --------------- +export interface TimelineCaptionProps { + children?: ReactNode; +} + +const TimelineCaption = ({ children }: TimelineCaptionProps) => ( +
+ {children} +
+); + +// Timeline.Body +// --------------- +export interface TimelineBodyProps { + className?: string; + children?: ReactNode; +} + +const TimelineBody = ({ className, children }: TimelineBodyProps) => ( +
{children}
+); + +// Props +// --------------- +export interface TimelineProps { + children?: ReactNode; +} + +// Component +// --------------- +export const Timeline = ({ children }: TimelineProps) => { + return ( +
+ {children} +
+ ); +}; + +Timeline.Item = TimelineItem; +Timeline.Header = TimelineHeader; +Timeline.Caption = TimelineCaption; +Timeline.Body = TimelineBody;