ui changes and cs prices
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
This commit is contained in:
138
src/lib/cs-prices/PriceChart.svelte
Normal file
138
src/lib/cs-prices/PriceChart.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { ECharts } from 'echarts/core';
|
||||
|
||||
export type PriceSeries = { key: string; name: string; points: [number, number][] };
|
||||
let { series, kind, currency }: { series: PriceSeries[]; kind: string; currency: string } =
|
||||
$props();
|
||||
let element: HTMLDivElement;
|
||||
let chart: ECharts | undefined;
|
||||
let ready = $state(false);
|
||||
let money = $derived(new Intl.NumberFormat('en-US', { style: 'currency', currency }));
|
||||
const escapeHtml = (value: string) =>
|
||||
value.replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!
|
||||
);
|
||||
|
||||
function update() {
|
||||
if (!chart) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const foreground = style.getPropertyValue('--site-text').trim();
|
||||
const muted = style.getPropertyValue('--site-muted').trim();
|
||||
const border = style.getPropertyValue('--site-border').trim();
|
||||
chart.setOption(
|
||||
{
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 78, right: 20, top: 28, bottom: 72 },
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
axisLine: { lineStyle: { color: border } },
|
||||
axisLabel: { color: muted },
|
||||
splitLine: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
axisLabel: { color: muted, formatter: (value: number) => money.format(value / 100) },
|
||||
splitLine: { lineStyle: { color: border } }
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside' },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 12,
|
||||
height: 18,
|
||||
borderColor: border,
|
||||
textStyle: { color: muted },
|
||||
fillerColor: 'rgba(127,127,127,.15)'
|
||||
}
|
||||
],
|
||||
tooltip:
|
||||
series.length > 100
|
||||
? { show: false }
|
||||
: {
|
||||
trigger: 'item',
|
||||
backgroundColor: style.getPropertyValue('--site-surface').trim(),
|
||||
borderColor: border,
|
||||
textStyle: { color: foreground },
|
||||
formatter: (params: { seriesName: string; value: [number, number] }) =>
|
||||
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString()} · ${money.format(params.value[1] / 100)}`
|
||||
},
|
||||
series: series.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
data: item.points,
|
||||
showSymbol: false,
|
||||
connectNulls: false,
|
||||
silent: series.length > 100,
|
||||
lineStyle: {
|
||||
width: series.length > 100 ? 0.7 : 1.8,
|
||||
opacity: series.length > 100 ? 0.42 : 0.9
|
||||
},
|
||||
itemStyle: {
|
||||
color: `hsl(${(index * 137.508) % 360} 65% ${style.getPropertyValue('--site-bg').trim() === '#000' ? '68%' : '38%'})`
|
||||
},
|
||||
emphasis: { focus: 'series', lineStyle: { width: 3, opacity: 1 } },
|
||||
large: true
|
||||
}))
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let resize: ResizeObserver | undefined;
|
||||
let theme: MutationObserver | undefined;
|
||||
import('echarts/core').then(async (echarts) => {
|
||||
const [
|
||||
{ LineChart },
|
||||
{ GridComponent, TooltipComponent, DataZoomComponent },
|
||||
{ CanvasRenderer }
|
||||
] = await Promise.all([
|
||||
import('echarts/charts'),
|
||||
import('echarts/components'),
|
||||
import('echarts/renderers')
|
||||
]);
|
||||
if (disposed) return;
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);
|
||||
chart = echarts.init(element, undefined, { renderer: 'canvas' });
|
||||
ready = true;
|
||||
update();
|
||||
resize = new ResizeObserver(() => chart?.resize());
|
||||
resize.observe(element);
|
||||
theme = new MutationObserver(update);
|
||||
theme.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme']
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
resize?.disconnect();
|
||||
theme?.disconnect();
|
||||
chart?.dispose();
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
if (ready) update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="chart"
|
||||
bind:this={element}
|
||||
role="img"
|
||||
aria-label={`${series.length} ${kind} price series chart`}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: min(62vh, 560px);
|
||||
min-height: 320px;
|
||||
}
|
||||
</style>
|
||||
12
src/lib/cs-prices/steam.test.ts
Normal file
12
src/lib/cs-prices/steam.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { steamDate } from './steam';
|
||||
|
||||
describe('Steam market history dates', () => {
|
||||
it('keeps hourly timestamps and older daily timestamps in UTC', () => {
|
||||
expect(steamDate('Sep 24 2024 01: +0')).toBe('2024-09-24T01:00:00.000Z');
|
||||
expect(steamDate('Jan 08 2016')).toBe('2016-01-08T00:00:00.000Z');
|
||||
});
|
||||
it('rejects an unknown format', () => {
|
||||
expect(() => steamDate('not a date')).toThrow('Unknown Steam date');
|
||||
});
|
||||
});
|
||||
23
src/lib/cs-prices/steam.ts
Normal file
23
src/lib/cs-prices/steam.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Parse the English UTC labels returned by Steam's market history endpoint. */
|
||||
export function steamDate(value: string): string {
|
||||
const match = /^(\w{3}) (\d{1,2}) (\d{4})(?: (\d{1,2}):)?/.exec(value);
|
||||
if (!match) throw new Error(`Unknown Steam date: ${value}`);
|
||||
const month = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
].indexOf(match[1]);
|
||||
if (month < 0) throw new Error(`Unknown Steam month: ${value}`);
|
||||
return new Date(
|
||||
Date.UTC(Number(match[3]), month, Number(match[2]), Number(match[4] ?? 0))
|
||||
).toISOString();
|
||||
}
|
||||
48
src/lib/cs-prices/taxonomy.test.ts
Normal file
48
src/lib/cs-prices/taxonomy.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dimensionsFromName } from './taxonomy';
|
||||
|
||||
describe('CS market dimensions', () => {
|
||||
it('groups normal and StatTrak wear variants under the same gun and skin', () => {
|
||||
const normal = dimensionsFromName('AK-47 | Redline (Field-Tested)');
|
||||
const stattrak = dimensionsFromName('StatTrak™ AK-47 | Redline (Minimal Wear)');
|
||||
expect(normal).toMatchObject({
|
||||
category: 'weapon',
|
||||
weapon_type: 'rifle',
|
||||
weapon: 'AK-47',
|
||||
skin: 'Redline',
|
||||
weapon_skin: 'AK-47 | Redline',
|
||||
wear: 'Field-Tested',
|
||||
stattrak: 'no'
|
||||
});
|
||||
expect(stattrak).toMatchObject({
|
||||
category: 'weapon',
|
||||
weapon_type: 'rifle',
|
||||
weapon: 'AK-47',
|
||||
skin: 'Redline',
|
||||
wear: 'Minimal Wear',
|
||||
stattrak: 'yes'
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes knives, gloves, souvenir weapons, and non-weapon entries', () => {
|
||||
expect(dimensionsFromName('★ Karambit | Doppler (Factory New)')).toMatchObject({
|
||||
category: 'knife',
|
||||
weapon: 'Karambit',
|
||||
skin: 'Doppler'
|
||||
});
|
||||
expect(dimensionsFromName('★ Sport Gloves | Vice (Field-Tested)')).toMatchObject({
|
||||
category: 'gloves',
|
||||
weapon: 'Sport Gloves'
|
||||
});
|
||||
expect(dimensionsFromName('Souvenir AWP | Dragon Lore (Factory New)')).toMatchObject({
|
||||
weapon: 'AWP',
|
||||
souvenir: 'yes'
|
||||
});
|
||||
expect(dimensionsFromName('Sticker | Crown (Foil)')).toMatchObject({
|
||||
category: 'sticker',
|
||||
skin: 'Crown',
|
||||
variant: 'Foil'
|
||||
});
|
||||
expect(dimensionsFromName('Revolution Case')).toMatchObject({ category: 'container' });
|
||||
});
|
||||
});
|
||||
72
src/lib/cs-prices/taxonomy.ts
Normal file
72
src/lib/cs-prices/taxonomy.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type Dimensions = Record<string, string>;
|
||||
|
||||
const weapons: Record<string, string> = Object.fromEntries(
|
||||
Object.entries({
|
||||
rifle: ['AK-47', 'AUG', 'FAMAS', 'Galil AR', 'M4A1-S', 'M4A4', 'SG 553', 'SG 556'],
|
||||
sniper: ['AWP', 'G3SG1', 'SCAR-20', 'SSG 08'],
|
||||
pistol: [
|
||||
'CZ75-Auto',
|
||||
'Desert Eagle',
|
||||
'Dual Berettas',
|
||||
'Five-SeveN',
|
||||
'Glock-18',
|
||||
'P2000',
|
||||
'P250',
|
||||
'R8 Revolver',
|
||||
'Tec-9',
|
||||
'USP-S'
|
||||
],
|
||||
smg: ['MAC-10', 'MP5-SD', 'MP7', 'MP9', 'P90', 'PP-Bizon', 'UMP-45'],
|
||||
heavy: ['MAG-7', 'Nova', 'Sawed-Off', 'XM1014', 'M249', 'Negev']
|
||||
}).flatMap(([type, names]) => names.map((name) => [name, type]))
|
||||
);
|
||||
|
||||
const wearNames = new Set([
|
||||
'Factory New',
|
||||
'Minimal Wear',
|
||||
'Field-Tested',
|
||||
'Well-Worn',
|
||||
'Battle-Scarred'
|
||||
]);
|
||||
|
||||
/** Derive useful facets from a market hash name; source-provided facets can override these. */
|
||||
export function dimensionsFromName(marketHashName: string): Dimensions {
|
||||
const result: Dimensions = {};
|
||||
let name = marketHashName;
|
||||
const starred = name.startsWith('★ ');
|
||||
if (starred) name = name.slice(2);
|
||||
if (name.startsWith('StatTrak™ ')) {
|
||||
result.stattrak = 'yes';
|
||||
name = name.slice('StatTrak™ '.length);
|
||||
} else result.stattrak = 'no';
|
||||
if (name.startsWith('Souvenir ')) {
|
||||
result.souvenir = 'yes';
|
||||
name = name.slice('Souvenir '.length);
|
||||
} else result.souvenir = 'no';
|
||||
const match = /^(.+?) \| (.+?)(?: \(([^)]+)\))?$/.exec(name);
|
||||
if (match && /^(Sticker|Patch|Graffiti|Sealed Graffiti|Charm)$/.test(match[1])) {
|
||||
result.category = match[1].toLowerCase();
|
||||
result.skin = match[2];
|
||||
if (match[3]) result.variant = match[3];
|
||||
} else if (match) {
|
||||
const [, base, finish, suffix] = match;
|
||||
result.weapon = base;
|
||||
result.skin = finish;
|
||||
result.weapon_skin = `${base} | ${finish}`;
|
||||
if (suffix && wearNames.has(suffix)) result.wear = suffix;
|
||||
else if (suffix) result.variant = suffix;
|
||||
if (weapons[base]) {
|
||||
result.category = 'weapon';
|
||||
result.weapon_type = weapons[base];
|
||||
} else if (/Gloves|Hand Wraps/i.test(base)) {
|
||||
result.category = 'gloves';
|
||||
result.weapon_type = 'gloves';
|
||||
} else if (starred || /Knife|Bayonet|Kukri|Karambit/i.test(base)) {
|
||||
result.category = 'knife';
|
||||
result.weapon_type = 'knife';
|
||||
} else result.category = 'other';
|
||||
} else if (/Case|Capsule|Package|Container/.test(name)) result.category = 'container';
|
||||
else if (/Music Kit/.test(name)) result.category = 'music kit';
|
||||
else result.category = 'other';
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user