add echarts chart components for gw scores

This commit is contained in:
Justin Edmund 2025-12-18 13:11:57 -08:00
parent 807cb8fb96
commit 0712fb20c0
7 changed files with 481 additions and 0 deletions

View file

@ -39,6 +39,7 @@
"@tanstack/svelte-query-devtools": "^6.0.2",
"@types/node": "^22",
"@vitest/browser": "^3.2.3",
"echarts": "^5.6.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-storybook": "^10.1.2",
@ -52,6 +53,7 @@
"storybook": "^10.1.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-echarts": "^1.0.0",
"svelte-preprocess": "^6.0.3",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",

View file

@ -108,6 +108,9 @@ importers:
'@vitest/browser':
specifier: ^3.2.3
version: 3.2.4(playwright@1.55.0)(vite@7.1.5(@types/node@22.18.1)(sass@1.92.1))(vitest@3.2.4)
echarts:
specifier: ^5.6.0
version: 5.6.0
eslint:
specifier: ^9.18.0
version: 9.35.0
@ -147,6 +150,9 @@ importers:
svelte-check:
specifier: ^4.0.0
version: 4.3.1(picomatch@4.0.3)(svelte@5.38.7)(typescript@5.9.2)
svelte-echarts:
specifier: ^1.0.0
version: 1.0.0(echarts@5.6.0)(svelte@5.38.7)
svelte-preprocess:
specifier: ^6.0.3
version: 6.0.3(postcss-load-config@3.1.4(postcss@8.5.6))(postcss@8.5.6)(sass@1.92.1)(svelte@5.38.7)(typescript@5.9.2)
@ -1607,6 +1613,9 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
echarts@5.6.0:
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@ -2519,6 +2528,12 @@ packages:
svelte: ^4.0.0 || ^5.0.0-next.0
typescript: '>=5.0.0'
svelte-echarts@1.0.0:
resolution: {integrity: sha512-o0VgdxGJt+Km+IrxNo35qTJFqDsvj65p7JwAlIrk7CsWjBKKniJaUV6hKbMDNf0S34Su0idWbWEdymJNPX3anA==}
peerDependencies:
echarts: ^5.0.0
svelte: '>=5'
svelte-eslint-parser@1.3.1:
resolution: {integrity: sha512-0Iztj5vcOVOVkhy1pbo5uA9r+d3yaVoE5XPc9eABIWDOSJZ2mOsZ4D+t45rphWCOr0uMw3jtSG2fh2e7GvKnPg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -2638,6 +2653,9 @@ packages:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -2885,6 +2903,9 @@ packages:
zod@4.1.5:
resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==}
zrender@5.6.1:
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@ -4291,6 +4312,11 @@ snapshots:
dom-accessibility-api@0.6.3: {}
echarts@5.6.0:
dependencies:
tslib: 2.3.0
zrender: 5.6.1
entities@4.5.0: {}
es-module-lexer@1.7.0: {}
@ -5422,6 +5448,11 @@ snapshots:
transitivePeerDependencies:
- picomatch
svelte-echarts@1.0.0(echarts@5.6.0)(svelte@5.38.7):
dependencies:
echarts: 5.6.0
svelte: 5.38.7
svelte-eslint-parser@1.3.1(svelte@5.38.7):
dependencies:
eslint-scope: 8.4.0
@ -5515,6 +5546,8 @@ snapshots:
ts-dedent@2.2.0: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
type-check@0.4.0:
@ -5758,4 +5791,8 @@ snapshots:
zod@4.1.5: {}
zrender@5.6.1:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {}

View file

@ -0,0 +1,83 @@
<svelte:options runes={true} />
<script lang="ts">
import { Chart } from 'svelte-echarts'
import { init, CHART_FONT_FAMILY } from './echarts-setup'
import { formatScore, formatScoreCompact } from '$lib/utils/gw'
import type { GwChartDataPoint } from '$lib/types/api/gw'
interface Props {
data: GwChartDataPoint[]
height?: number
}
let { data, height = 300 }: Props = $props()
const options = $derived({
textStyle: { fontFamily: CHART_FONT_FAMILY },
tooltip: {
trigger: 'axis' as const,
formatter: (params: unknown) => {
const p = params as Array<{ seriesName: string; value: number; name: string }>
if (!p[0]) return ''
const crew = p.find((item) => item.seriesName === 'Our Crew')
const opp = p.find((item) => item.seriesName === 'Opponent')
return `${p[0].name}<br/>Our Crew: ${formatScore(crew?.value ?? 0)}<br/>Opponent: ${formatScore(opp?.value ?? 0)}`
}
},
legend: {
data: ['Our Crew', 'Opponent'],
bottom: 0
},
grid: {
left: 48,
right: 16,
top: 24,
bottom: 48
},
xAxis: {
type: 'category' as const,
data: data.map((d) => d.roundLabel),
axisLabel: { fontSize: 11 }
},
yAxis: {
type: 'value' as const,
axisLabel: {
formatter: (v: number) => formatScoreCompact(v),
fontSize: 11
}
},
series: [
{
name: 'Our Crew',
type: 'line' as const,
data: data.map((d) => d.crewScore),
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#2563eb' },
itemStyle: { color: '#2563eb' }
},
{
name: 'Opponent',
type: 'line' as const,
data: data.map((d) => d.opponentScore),
smooth: true,
symbol: 'diamond',
symbolSize: 8,
lineStyle: { width: 2, color: '#dc2626' },
itemStyle: { color: '#dc2626' }
}
]
})
</script>
<div class="chart-container" style:height="{height}px">
<Chart {init} {options} />
</div>
<style lang="scss">
.chart-container {
width: 100%;
}
</style>

View file

@ -0,0 +1,129 @@
<svelte:options runes={true} />
<script lang="ts">
import { Chart } from 'svelte-echarts'
import { init, CHART_FONT_FAMILY } from './echarts-setup'
import { formatScore, formatScoreCompact, type HistoryDataPoint } from '$lib/utils/gw'
interface Props {
data: HistoryDataPoint[]
height?: number
}
let { data, height = 350 }: Props = $props()
// Calculate ~2 years of events (roughly 12 GWs per year = 24 events)
const defaultViewportSize = 24
const startPercent = $derived(
data.length > defaultViewportSize
? ((data.length - defaultViewportSize) / data.length) * 100
: 0
)
const options = $derived({
textStyle: { fontFamily: CHART_FONT_FAMILY },
tooltip: {
trigger: 'axis' as const,
formatter: (params: unknown) => {
const p = params as Array<{ name: string; value: number; dataIndex: number }>
const point = p[0]
if (!point) return ''
const dataPoint = data[point.dataIndex]
return `${point.name}<br/>Score: ${formatScore(point.value)}<br/>${dataPoint?.date ?? ''}`
}
},
toolbox: {
feature: {
dataZoom: {
yAxisIndex: 'none' as const,
title: { zoom: 'Zoom', back: 'Reset' }
},
restore: { title: 'Reset' }
},
right: 20
},
dataZoom: [
{
type: 'slider' as const,
xAxisIndex: 0,
start: startPercent,
end: 100,
height: 30,
bottom: 10,
borderColor: 'transparent',
backgroundColor: 'rgba(0,0,0,0.05)',
fillerColor: 'rgba(37,99,235,0.2)',
handleStyle: { color: '#2563eb' }
},
{
type: 'inside' as const,
xAxisIndex: 0,
start: startPercent,
end: 100,
zoomOnMouseWheel: 'shift',
moveOnMouseMove: true,
moveOnMouseWheel: true
}
],
grid: {
left: 48,
right: 16,
bottom: 80,
top: 40
},
xAxis: {
type: 'category' as const,
data: data.map((d) => d.eventLabel),
axisLabel: {
rotate: 45,
interval: 'auto' as const,
fontSize: 11
}
},
yAxis: {
type: 'value' as const,
axisLabel: {
formatter: (v: number) => formatScoreCompact(v),
fontSize: 11
}
},
series: [
{
type: 'line' as const,
data: data.map((d) => d.totalScore),
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#2563eb' },
itemStyle: { color: '#2563eb' },
areaStyle: { opacity: 0.1, color: '#2563eb' }
}
]
})
</script>
<div class="chart-wrapper">
<div class="chart-container" style:height="{height}px">
<Chart {init} {options} />
</div>
<p class="chart-hint">Drag to pan &bull; Shift+scroll to zoom &bull; Use slider below chart</p>
</div>
<style lang="scss">
@use '$src/themes/typography' as typography;
.chart-wrapper {
width: 100%;
}
.chart-container {
width: 100%;
}
.chart-hint {
text-align: center;
font-size: typography.$font-small;
color: var(--text-tertiary);
margin: 4px 0 0;
}
</style>

View file

@ -0,0 +1,124 @@
<svelte:options runes={true} />
<script lang="ts">
import { Chart } from 'svelte-echarts'
import { init, CHART_FONT_FAMILY } from './echarts-setup'
import { formatScore, formatScoreCompact, type PlayerRoundScore } from '$lib/utils/gw'
interface Props {
playerScores: Map<string, { name: string; scores: PlayerRoundScore[] }>
height?: number
}
let { playerScores, height = 400 }: Props = $props()
// Color palette for 30 players
const colors = [
'#2563eb',
'#dc2626',
'#16a34a',
'#ca8a04',
'#9333ea',
'#0891b2',
'#ea580c',
'#db2777',
'#4f46e5',
'#65a30d',
'#0d9488',
'#be185d',
'#7c3aed',
'#b91c1c',
'#047857',
'#a16207',
'#6366f1',
'#c2410c',
'#0369a1',
'#4338ca',
'#15803d',
'#b45309',
'#7e22ce',
'#e11d48',
'#059669',
'#d97706',
'#8b5cf6',
'#f43f5e',
'#10b981',
'#f59e0b'
]
// Get round labels from first player
const roundLabels = $derived([...playerScores.values()][0]?.scores.map((s) => s.roundLabel) ?? [])
// Get player list for legend
const playerList = $derived([...playerScores.values()].map((p) => p.name))
// Default: top 5 players visible
const defaultSelected = $derived(
Object.fromEntries([...playerScores.values()].map((p, i) => [p.name, i < 5]))
)
const options = $derived({
textStyle: { fontFamily: CHART_FONT_FAMILY },
tooltip: {
trigger: 'axis' as const,
formatter: (params: unknown) => {
const p = params as Array<{
marker: string
seriesName: string
value: number
name: string
}>
if (!p[0]) return ''
const sorted = [...p].sort((a, b) => b.value - a.value).slice(0, 10)
const lines = sorted.map(
(item) => `${item.marker} ${item.seriesName}: ${formatScore(item.value)}`
)
return `${p[0].name}<br/>${lines.join('<br/>')}`
}
},
legend: {
type: 'scroll' as const,
bottom: 0,
data: playerList,
selected: defaultSelected
},
grid: {
left: 48,
right: 16,
top: 24,
bottom: 80
},
xAxis: {
type: 'category' as const,
data: roundLabels,
axisLabel: { fontSize: 11 }
},
yAxis: {
type: 'value' as const,
axisLabel: {
formatter: (v: number) => formatScoreCompact(v),
fontSize: 11
}
},
series: [...playerScores.entries()].map(([, player], i) => ({
name: player.name,
type: 'line' as const,
data: player.scores.map((s) => s.cumulative),
smooth: true,
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: colors[i % colors.length] },
lineStyle: { width: 2 }
}))
})
</script>
<div class="chart-container" style:height="{height}px">
<Chart {init} {options} />
</div>
<style lang="scss">
.chart-container {
width: 100%;
}
</style>

View file

@ -0,0 +1,71 @@
<svelte:options runes={true} />
<script lang="ts">
import { Chart } from 'svelte-echarts'
import { init, CHART_FONT_FAMILY } from './echarts-setup'
import { formatScore, formatScoreCompact, type PlayerRoundScore } from '$lib/utils/gw'
interface Props {
data: PlayerRoundScore[]
title?: string
height?: number
}
let { data, title, height = 300 }: Props = $props()
const options = $derived({
textStyle: { fontFamily: CHART_FONT_FAMILY },
title: title
? { text: title, left: 'center', textStyle: { fontSize: 14, fontFamily: CHART_FONT_FAMILY } }
: undefined,
tooltip: {
trigger: 'axis' as const,
formatter: (params: unknown) => {
const p = params as Array<{ name: string; value: number }>
const point = p[0]
if (!point) return ''
return `${point.name}<br/>Score: ${formatScore(point.value)}`
}
},
grid: {
left: 48,
right: 16,
top: title ? 40 : 24,
bottom: 40
},
xAxis: {
type: 'category' as const,
data: data.map((d) => d.roundLabel),
axisLabel: { rotate: 45, fontSize: 11 }
},
yAxis: {
type: 'value' as const,
axisLabel: {
formatter: (v: number) => formatScoreCompact(v),
fontSize: 11
}
},
series: [
{
type: 'line' as const,
data: data.map((d) => d.cumulative),
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#2563eb' },
itemStyle: { color: '#2563eb' },
areaStyle: { opacity: 0.1, color: '#2563eb' }
}
]
})
</script>
<div class="chart-container" style:height="{height}px">
<Chart {init} {options} />
</div>
<style lang="scss">
.chart-container {
width: 100%;
}
</style>

View file

@ -0,0 +1,35 @@
/**
* ECharts Setup Module
*
* Configures tree-shaking for ECharts by only importing the components we need.
* This keeps the bundle size reasonable while providing full chart functionality.
*/
import { init, use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomComponent,
ToolboxComponent
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
// Register only the components we need
use([
LineChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomComponent,
ToolboxComponent,
CanvasRenderer
])
// Shared font family matching the app's typography
export const CHART_FONT_FAMILY = "'AGrot', system-ui, sans-serif"
export { init }