cs-prices: full ByMykel catalog, every config, daily multi-source prices, item pages with working charts
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
All checks were successful
Deploy production / deploy (push) Successful in 1m18s
- cs.skins catalog (weapons, knives, gloves, stickers, cases, agents, charms, …) refreshed weekly - every market config: wears in float range, StatTrak, Souvenir, Doppler/Gamma phases - cs.daily_prices (one row per config/stream/day, idempotent) from Skinport, Steam, CSFloat, Buff163 - cs.price_windows for 24h/7d/30d/90d sale aggregates, cs.fx_rates (ECB) - scripts/cs-prices.ts daily|catalog|prices; legacy ingest fixed for the new unique index - skin browser + /projects/cs-prices/[slug] with variant/wear/phase switcher and real charts - charts show points for short histories and pad sparse time axes
This commit is contained in:
@@ -2,13 +2,30 @@
|
||||
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();
|
||||
export type PriceSeries = {
|
||||
key: string;
|
||||
name: string;
|
||||
points: [number, number][];
|
||||
color?: string;
|
||||
dashed?: boolean;
|
||||
};
|
||||
let {
|
||||
series,
|
||||
kind,
|
||||
currency,
|
||||
height = 'min(62vh, 560px)',
|
||||
zoom = true
|
||||
}: {
|
||||
series: PriceSeries[];
|
||||
kind: string;
|
||||
currency: string;
|
||||
height?: string;
|
||||
zoom?: boolean;
|
||||
} = $props();
|
||||
let element: HTMLDivElement;
|
||||
let chart: ECharts | undefined;
|
||||
let ready = $state(false);
|
||||
let money = $derived(new Intl.NumberFormat('en-US', { style: 'currency', currency }));
|
||||
let money = $derived(new Intl.NumberFormat('nl-NL', { style: 'currency', currency }));
|
||||
const escapeHtml = (value: string) =>
|
||||
value.replace(
|
||||
/[&<>"']/g,
|
||||
@@ -22,15 +39,53 @@
|
||||
const foreground = style.getPropertyValue('--site-text').trim();
|
||||
const muted = style.getPropertyValue('--site-muted').trim();
|
||||
const border = style.getPropertyValue('--site-border').trim();
|
||||
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
|
||||
const many = series.length > 100;
|
||||
const legend = series.length > 1 && series.length <= 12;
|
||||
const surface = style.getPropertyValue('--site-surface').trim();
|
||||
// A few days of daily data would otherwise be stretched into hour ticks; pad to a week.
|
||||
let first = Infinity;
|
||||
let last = -Infinity;
|
||||
for (const line of series)
|
||||
for (const [t] of line.points) {
|
||||
if (t < first) first = t;
|
||||
if (t > last) last = t;
|
||||
}
|
||||
const day = 86_400_000;
|
||||
const short = Number.isFinite(first) && last - first < 7 * day;
|
||||
const pad = short ? (7 * day - (last - first)) / 2 : 0;
|
||||
chart.setOption(
|
||||
{
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 78, right: 20, top: 28, bottom: 72 },
|
||||
grid: {
|
||||
left: 64,
|
||||
right: 14,
|
||||
top: legend ? 40 : 20,
|
||||
bottom: zoom ? 64 : 28,
|
||||
containLabel: false
|
||||
},
|
||||
legend: legend
|
||||
? {
|
||||
top: 0,
|
||||
left: 0,
|
||||
icon: 'roundRect',
|
||||
itemWidth: 10,
|
||||
itemHeight: 4,
|
||||
textStyle: { color: muted, fontSize: 11 }
|
||||
}
|
||||
: { show: false },
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
min: short ? first - pad : undefined,
|
||||
max: short ? last + pad : undefined,
|
||||
minInterval: day,
|
||||
axisLine: { lineStyle: { color: border } },
|
||||
axisLabel: { color: muted },
|
||||
axisLabel: {
|
||||
color: muted,
|
||||
hideOverlap: true,
|
||||
formatter: { year: '{yyyy}', month: '{MMM}', day: '{d} {MMM}', hour: '{d} {MMM}' }
|
||||
},
|
||||
splitLine: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
@@ -39,44 +94,71 @@
|
||||
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 }
|
||||
dataZoom: zoom
|
||||
? [
|
||||
{ type: 'inside' },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 10,
|
||||
height: 18,
|
||||
borderColor: border,
|
||||
textStyle: { color: muted },
|
||||
fillerColor: 'rgba(127,127,127,.15)'
|
||||
}
|
||||
]
|
||||
: [],
|
||||
tooltip: many
|
||||
? { show: false }
|
||||
: legend || series.length === 1
|
||||
? {
|
||||
trigger: 'axis',
|
||||
backgroundColor: surface,
|
||||
borderColor: border,
|
||||
textStyle: { color: foreground, fontSize: 12 },
|
||||
formatter: (
|
||||
params: {
|
||||
seriesName: string;
|
||||
value: [number, number];
|
||||
color: string;
|
||||
}[]
|
||||
) =>
|
||||
params.length
|
||||
? `${new Date(params[0].value[0]).toLocaleDateString('nl-NL')}<br>` +
|
||||
params
|
||||
.map(
|
||||
(p) =>
|
||||
`<span style="color:${p.color}">●</span> ${escapeHtml(p.seriesName)} · <b>${money.format(p.value[1] / 100)}</b>`
|
||||
)
|
||||
.join('<br>')
|
||||
: ''
|
||||
}
|
||||
: {
|
||||
trigger: 'item',
|
||||
backgroundColor: style.getPropertyValue('--site-surface').trim(),
|
||||
backgroundColor: surface,
|
||||
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)}`
|
||||
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString('nl-NL')} · ${money.format(params.value[1] / 100)}`
|
||||
},
|
||||
series: series.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
data: item.points,
|
||||
showSymbol: false,
|
||||
// Few points (a new archive, or one sweep) would be invisible as a bare line.
|
||||
showSymbol: item.points.length <= 60,
|
||||
symbolSize: item.points.length <= 3 ? 7 : 4,
|
||||
connectNulls: false,
|
||||
silent: series.length > 100,
|
||||
silent: many,
|
||||
lineStyle: {
|
||||
width: series.length > 100 ? 0.7 : 1.8,
|
||||
opacity: series.length > 100 ? 0.42 : 0.9
|
||||
width: many ? 0.7 : 1.8,
|
||||
opacity: many ? 0.42 : 0.9,
|
||||
type: item.dashed ? 'dashed' : 'solid'
|
||||
},
|
||||
itemStyle: {
|
||||
color: `hsl(${(index * 137.508) % 360} 65% ${style.getPropertyValue('--site-bg').trim() === '#000' ? '68%' : '38%'})`
|
||||
color: item.color ?? `hsl(${(index * 137.508) % 360} 65% ${dark ? '68%' : '38%'})`
|
||||
},
|
||||
emphasis: { focus: 'series', lineStyle: { width: 3, opacity: 1 } },
|
||||
large: true
|
||||
large: many
|
||||
}))
|
||||
},
|
||||
true
|
||||
@@ -90,7 +172,7 @@
|
||||
import('echarts/core').then(async (echarts) => {
|
||||
const [
|
||||
{ LineChart },
|
||||
{ GridComponent, TooltipComponent, DataZoomComponent },
|
||||
{ GridComponent, TooltipComponent, DataZoomComponent, LegendComponent },
|
||||
{ CanvasRenderer }
|
||||
] = await Promise.all([
|
||||
import('echarts/charts'),
|
||||
@@ -98,7 +180,14 @@
|
||||
import('echarts/renderers')
|
||||
]);
|
||||
if (disposed) return;
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);
|
||||
echarts.use([
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
DataZoomComponent,
|
||||
LegendComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
chart = echarts.init(element, undefined, { renderer: 'canvas' });
|
||||
ready = true;
|
||||
update();
|
||||
@@ -107,7 +196,7 @@
|
||||
theme = new MutationObserver(update);
|
||||
theme.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme']
|
||||
attributeFilter: ['data-theme', 'class']
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
@@ -118,12 +207,16 @@
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
// Track the props so a new selection redraws.
|
||||
void series;
|
||||
void currency;
|
||||
if (ready) update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="chart"
|
||||
style:height
|
||||
bind:this={element}
|
||||
role="img"
|
||||
aria-label={`${series.length} ${kind} price series chart`}
|
||||
@@ -132,7 +225,6 @@
|
||||
<style>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: min(62vh, 560px);
|
||||
min-height: 320px;
|
||||
min-height: 260px;
|
||||
}
|
||||
</style>
|
||||
|
||||
43
src/lib/cs-prices/catalog.test.ts
Normal file
43
src/lib/cs-prices/catalog.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { configRank, parseConfig, slugify } from './catalog';
|
||||
|
||||
describe('parseConfig', () => {
|
||||
it('splits knives, StatTrak and wear', () => {
|
||||
expect(parseConfig('★ StatTrak™ Karambit | Doppler (Factory New)')).toEqual({
|
||||
starred: true,
|
||||
stattrak: true,
|
||||
souvenir: false,
|
||||
wear: 'Factory New',
|
||||
base: 'Karambit | Doppler',
|
||||
display: '★ Karambit | Doppler'
|
||||
});
|
||||
});
|
||||
it('handles souvenirs and wearless items', () => {
|
||||
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').souvenir).toBe(true);
|
||||
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').base).toBe('AWP | Dragon Lore');
|
||||
expect(parseConfig('Sticker | Heroic (Holo) | Rio 2022').base).toBe(
|
||||
'Sticker | Heroic (Holo) | Rio 2022'
|
||||
);
|
||||
expect(parseConfig('Paris 2023 Mirage Souvenir Package').souvenir).toBe(false);
|
||||
expect(parseConfig('StatTrak™ Music Kit | Daniel Sadowski, Crimson Assault').base).toBe(
|
||||
'Music Kit | Daniel Sadowski, Crimson Assault'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('makes stable slugs', () => {
|
||||
expect(slugify('★ Karambit | Doppler')).toBe('karambit-doppler');
|
||||
expect(slugify('AK-47 | Redline')).toBe('ak-47-redline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configRank', () => {
|
||||
it('sorts normal, then StatTrak, by wear and phase', () => {
|
||||
const a = configRank({ stattrak: false, souvenir: false, wear: 'Factory New', phase: 'Ruby' });
|
||||
const b = configRank({ stattrak: false, souvenir: false, wear: 'Minimal Wear', phase: null });
|
||||
const c = configRank({ stattrak: true, souvenir: false, wear: 'Factory New', phase: null });
|
||||
expect(a).toBeLessThan(b);
|
||||
expect(b).toBeLessThan(c);
|
||||
});
|
||||
});
|
||||
87
src/lib/cs-prices/catalog.ts
Normal file
87
src/lib/cs-prices/catalog.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Pure helpers shared by scripts/cs-prices.ts and the item pages: how a market hash name maps to
|
||||
// its skin (the page that groups every wear, StatTrak, Souvenir and phase config).
|
||||
|
||||
export const wearOrder = [
|
||||
'Factory New',
|
||||
'Minimal Wear',
|
||||
'Field-Tested',
|
||||
'Well-Worn',
|
||||
'Battle-Scarred'
|
||||
] as const;
|
||||
export const wearShort: Record<string, string> = {
|
||||
'Factory New': 'FN',
|
||||
'Minimal Wear': 'MW',
|
||||
'Field-Tested': 'FT',
|
||||
'Well-Worn': 'WW',
|
||||
'Battle-Scarred': 'BS'
|
||||
};
|
||||
export const phaseOrder = [
|
||||
'Phase 1',
|
||||
'Phase 2',
|
||||
'Phase 3',
|
||||
'Phase 4',
|
||||
'Ruby',
|
||||
'Sapphire',
|
||||
'Black Pearl',
|
||||
'Emerald'
|
||||
] as const;
|
||||
|
||||
const wearSuffix = / \((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)$/;
|
||||
|
||||
export type Config = {
|
||||
starred: boolean;
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
wear: string | null;
|
||||
/** Name without ★, StatTrak™, Souvenir or wear: the skin grouping key. */
|
||||
base: string;
|
||||
/** Display name of the skin: base with ★ for knives and gloves. */
|
||||
display: string;
|
||||
};
|
||||
|
||||
export function parseConfig(marketHashName: string): Config {
|
||||
let name = marketHashName.trim();
|
||||
const starred = name.startsWith('★ ');
|
||||
if (starred) name = name.slice(2);
|
||||
let stattrak = false;
|
||||
let souvenir = false;
|
||||
if (name.startsWith('StatTrak™ ')) {
|
||||
stattrak = true;
|
||||
name = name.slice('StatTrak™ '.length);
|
||||
}
|
||||
if (name.startsWith('Souvenir ') && name.includes(' | ')) {
|
||||
souvenir = true;
|
||||
name = name.slice('Souvenir '.length);
|
||||
}
|
||||
const wear = wearSuffix.exec(name)?.[1] ?? null;
|
||||
if (wear) name = name.replace(wearSuffix, '');
|
||||
return { starred, stattrak, souvenir, wear, base: name, display: starred ? `★ ${name}` : name };
|
||||
}
|
||||
|
||||
export function slugify(value: string) {
|
||||
return (
|
||||
value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/★/g, '')
|
||||
.replace(/™/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 120) || 'item'
|
||||
);
|
||||
}
|
||||
|
||||
/** Order configs: normal before StatTrak before Souvenir, then wear, then phase. */
|
||||
export function configRank(row: {
|
||||
stattrak: boolean;
|
||||
souvenir: boolean;
|
||||
wear: string | null;
|
||||
phase: string | null;
|
||||
}) {
|
||||
const variant = row.souvenir ? 2 : row.stattrak ? 1 : 0;
|
||||
const wear = row.wear ? wearOrder.indexOf(row.wear as (typeof wearOrder)[number]) + 1 : 0;
|
||||
const phase = row.phase ? phaseOrder.indexOf(row.phase as (typeof phaseOrder)[number]) + 1 : 0;
|
||||
return variant * 1000 + wear * 20 + phase;
|
||||
}
|
||||
Reference in New Issue
Block a user