initial commit
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// crafts.js
|
||||
(() => {
|
||||
const API = 'https://api.tarkov.dev/graphql'; // official GraphQL endpoint (playground at /)
|
||||
|
||||
// ---------- GraphQL ----------
|
||||
// 1) All crafts: structure only (station, required items, rewards).
|
||||
// NOTE: No variables here—keeps it compatible and avoids "unused variable" errors.
|
||||
const CRAFTS_Q = `
|
||||
query AllCrafts {
|
||||
crafts {
|
||||
id
|
||||
duration
|
||||
station { name } # station.level is not in the public schema we saw—just show the name.
|
||||
requiredItems {
|
||||
count
|
||||
item { id name }
|
||||
}
|
||||
rewardItems {
|
||||
count
|
||||
item { id name }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// 2) Items by IDs with prices for a specific mode (regular/pve)
|
||||
// Official examples show avg24hPrice and sellFor { price, source } on items.
|
||||
const ITEMS_BY_IDS_Q = `
|
||||
query ItemsByIds($ids: [ID!], $mode: GameMode, $lang: LanguageCode) {
|
||||
items(ids: $ids, gameMode: $mode, lang: $lang) {
|
||||
id
|
||||
name
|
||||
avg24hPrice
|
||||
sellFor { price source }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------- DOM ----------
|
||||
const modeRadios = Array.from(document.querySelectorAll('input[name="hc-mode"]'));
|
||||
const metricSel = document.getElementById('hc-metric');
|
||||
const topNInput = document.getElementById('hc-topn');
|
||||
const refreshBtn = document.getElementById('hc-refresh');
|
||||
const updatedEl = document.getElementById('hc-updated');
|
||||
const statusEl = document.getElementById('hc-status');
|
||||
const tbody = document.getElementById('hc-tbody');
|
||||
const chartCanvas = document.getElementById('hc-chart');
|
||||
const applyFeeChk = document.getElementById('hc-applyfee');
|
||||
const feePctInput = document.getElementById('hc-fee');
|
||||
|
||||
let chart;
|
||||
let craftsCache = [];
|
||||
let pricesCache = new Map(); // id -> item pricing record
|
||||
|
||||
// ---------- Helpers ----------
|
||||
const fmt = n => '₽' + new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(Math.round(n || 0));
|
||||
const nowStr = () => new Date().toLocaleTimeString();
|
||||
const getMode = () => (modeRadios.find(r => r.checked)?.value) || 'regular';
|
||||
|
||||
function fleaPriceFromSellFor(sellFor) {
|
||||
if (!Array.isArray(sellFor)) return 0;
|
||||
const flea = sellFor.find(x => x?.source === 'Flea Market');
|
||||
return Number(flea?.price || 0);
|
||||
}
|
||||
|
||||
function getUnitPrice(itemRec, metric) {
|
||||
if (!itemRec) return 0;
|
||||
if (metric === 'avg24hPrice') return Number(itemRec.avg24hPrice || 0);
|
||||
// default flea: try sellFor(Flea Market), fallback to avg24h
|
||||
return fleaPriceFromSellFor(itemRec.sellFor) || Number(itemRec.avg24hPrice || 0);
|
||||
}
|
||||
|
||||
function netAfterFee(gross, applyFee, feePct) {
|
||||
if (!applyFee) return gross;
|
||||
const pct = Math.max(0, Math.min(100, Number(feePct || 0)));
|
||||
return gross * (1 - pct / 100);
|
||||
}
|
||||
|
||||
function collectIds(crafts) {
|
||||
const ids = new Set();
|
||||
for (const c of crafts) {
|
||||
for (const ri of (c.requiredItems || [])) ids.add(ri.item?.id);
|
||||
for (const ro of (c.rewardItems || [])) ids.add(ro.item?.id);
|
||||
}
|
||||
ids.delete(undefined);
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
async function gql(query, variables) {
|
||||
const resp = await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const json = await resp.json();
|
||||
if (json.errors) throw new Error(json.errors[0]?.message || 'GraphQL error');
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function fetchCrafts() {
|
||||
// Fix: no variables passed—prevents "Variable $lang is never used" error.
|
||||
const data = await gql(CRAFTS_Q);
|
||||
return data?.crafts ?? [];
|
||||
}
|
||||
|
||||
async function fetchItemsByIds(ids, mode) {
|
||||
// batch to avoid giant payloads
|
||||
const out = new Map();
|
||||
const BATCH = 80;
|
||||
for (let i = 0; i < ids.length; i += BATCH) {
|
||||
const slice = ids.slice(i, i + BATCH);
|
||||
const data = await gql(ITEMS_BY_IDS_Q, { ids: slice, mode, lang: 'en' });
|
||||
for (const it of data?.items || []) out.set(it.id, it);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarize(crafts, metric, applyFee, feePct) {
|
||||
const rows = [];
|
||||
for (const c of crafts) {
|
||||
// Inputs (cost)
|
||||
const inputs = (c.requiredItems || []).map(ri => {
|
||||
const rec = pricesCache.get(ri.item?.id);
|
||||
const unit = getUnitPrice(rec, metric);
|
||||
return { name: ri.item?.name || 'Unknown', count: ri.count || 0, unit, total: unit * (ri.count || 0) };
|
||||
});
|
||||
|
||||
// Outputs (sell)
|
||||
const outputs = (c.rewardItems || []).map(ro => {
|
||||
const rec = pricesCache.get(ro.item?.id);
|
||||
const unit = getUnitPrice(rec, metric);
|
||||
return { name: ro.item?.name || 'Unknown', count: ro.count || 0, unit, total: unit * (ro.count || 0) };
|
||||
});
|
||||
|
||||
const cost = inputs.reduce((s, x) => s + x.total, 0);
|
||||
const sellGross = outputs.reduce((s, x) => s + x.total, 0);
|
||||
const sellNet = netAfterFee(sellGross, applyFee, feePct);
|
||||
const profit = sellNet - cost;
|
||||
|
||||
// Only station name (no level), because station.level isn't public in the docs/examples we referenced.
|
||||
const stationLabel = c.station?.name || 'Station';
|
||||
|
||||
let craftName = 'Unknown craft';
|
||||
if (outputs.length === 1) craftName = `${outputs[0].name} ×${outputs[0].count}`;
|
||||
else if (outputs.length > 1) craftName = `${outputs[0].name} ×${outputs[0].count} (+${outputs.length - 1} other)`;
|
||||
|
||||
rows.push({
|
||||
id: c.id,
|
||||
craft: craftName,
|
||||
station: stationLabel,
|
||||
cost, sellGross, sellNet, profit,
|
||||
inputs
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => b.profit - a.profit);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
tbody.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = `<tr><td class="pc-muted" colspan="7">No data</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const r of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td data-label="Craft">${r.craft}</td>
|
||||
<td data-label="Station">${r.station}</td>
|
||||
<td data-label="Cost" class="right">${fmt(r.cost)}</td>
|
||||
<td data-label="Sell (gross)" class="right">${fmt(r.sellGross)}</td>
|
||||
<td data-label="Sell (net)" class="right">${fmt(r.sellNet)}</td>
|
||||
<td data-label="Profit" class="right ${r.profit >= 0 ? 'good' : 'bad'}">${fmt(r.profit)}</td>
|
||||
<td data-label="Inputs">${r.inputs.map(i => `${i.name} ×${i.count} (${fmt(i.unit)})`).join(', ')}</td>
|
||||
`;
|
||||
frag.appendChild(tr);
|
||||
}
|
||||
tbody.appendChild(frag);
|
||||
}
|
||||
|
||||
function renderChart(rows, applyFee) {
|
||||
const labels = rows.map(r => r.craft);
|
||||
const costs = rows.map(r => r.cost);
|
||||
const sells = rows.map(r => applyFee ? r.sellNet : r.sellGross);
|
||||
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
if (chart) chart.destroy();
|
||||
chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Cost to craft', data: costs, backgroundColor: 'rgba(255,99,132,.6)' },
|
||||
{ label: applyFee ? 'Sell (net)' : 'Sell (gross)', data: sells, backgroundColor: 'rgba(75,192,192,.7)' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
afterBody(ctx) {
|
||||
const i = ctx[0].dataIndex;
|
||||
const r = rows[i];
|
||||
return [`Station: ${r.station}`, `Profit: ${fmt(r.profit)}`];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { title: { display: true, text: 'Rubles (₽)' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const mode = getMode(); // 'regular' | 'pve' (items supports gameMode)
|
||||
const metric = metricSel.value; // 'flea' | 'avg24hPrice'
|
||||
const topN = Math.max(5, Math.min(200, parseInt(topNInput.value || '30', 10)));
|
||||
const applyFee = applyFeeChk.checked;
|
||||
const feePct = parseFloat(feePctInput.value || '0');
|
||||
|
||||
statusEl.textContent = 'Loading latest data…';
|
||||
try {
|
||||
// Step 1: crafts (structure)
|
||||
const crafts = await fetchCrafts();
|
||||
craftsCache = crafts;
|
||||
|
||||
// Step 2: price lookup by IDs in selected mode
|
||||
const ids = collectIds(crafts);
|
||||
pricesCache = await fetchItemsByIds(ids, mode);
|
||||
|
||||
const allRows = summarize(craftsCache, metric, applyFee, feePct);
|
||||
const rows = allRows.slice(0, topN);
|
||||
|
||||
renderTable(rows);
|
||||
renderChart(rows, applyFee);
|
||||
|
||||
updatedEl.textContent = `Data last updated: ${nowStr()}`;
|
||||
statusEl.textContent = `Showing top ${rows.length} crafts by profit — Mode: ${mode.toUpperCase()}, Metric: ${metric}, Fee: ${applyFee ? (feePct + '%') : 'off'}.`;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
tbody.innerHTML = `<tr><td class="pc-muted" colspan="7">Error: ${String(err.message || err)}</td></tr>`;
|
||||
statusEl.textContent = 'Failed to load data. Try again.';
|
||||
}
|
||||
}
|
||||
|
||||
// Events & timers
|
||||
modeRadios.forEach(r => r.addEventListener('change', refresh));
|
||||
metricSel.addEventListener('change', refresh);
|
||||
topNInput.addEventListener('input', refresh);
|
||||
refreshBtn.addEventListener('click', refresh);
|
||||
applyFeeChk.addEventListener('change', refresh);
|
||||
feePctInput.addEventListener('input', refresh);
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5 * 60 * 1000); // every 5 minutes
|
||||
})();
|
||||
Reference in New Issue
Block a user