initial commit
@@ -0,0 +1,318 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>ESCAPE FROM TARKOV COMPANION — Ammo</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<!-- Site-wide styles -->
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<!-- Page-scoped visuals only -->
|
||||||
|
<style>
|
||||||
|
/* ====== Layout helpers (match your theme) ====== */
|
||||||
|
.ammo-section {
|
||||||
|
border: 1px solid rgba(0, 255, 102, .25);
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
box-shadow: 0 0 14px rgba(0, 255, 102, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 255, 102, .08);
|
||||||
|
border: 1px solid rgba(0, 255, 102, .25);
|
||||||
|
color: #a7f3d0;
|
||||||
|
font-size: .85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
select,
|
||||||
|
input[type="number"],
|
||||||
|
input[type="text"],
|
||||||
|
button {
|
||||||
|
padding: .45rem .6rem;
|
||||||
|
background: #151924;
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid #2a3246;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: #1f2636;
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid #2a3246;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .5rem .8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #273049;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Table ====== */
|
||||||
|
.table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 255, 102, .15);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.right,
|
||||||
|
td.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #0b0f0c;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ammo-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ammo-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(0, 255, 102, .18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Mobile card view (<= 680px) ====== */
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
table thead {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
tbody,
|
||||||
|
tr,
|
||||||
|
td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border: 1px solid rgba(0, 255, 102, .25);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
margin: .75rem 0;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 255, 102, .06);
|
||||||
|
background: rgba(0, 0, 0, .2);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid rgba(0, 255, 102, .15);
|
||||||
|
padding: .4rem 0;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
display: block;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: #9aa0a6;
|
||||||
|
margin-bottom: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
td[data-label="Actions"] {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
td[data-label="Ammo"] {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e5ffe5;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use a clean UI stack only for the clickable ammo name */
|
||||||
|
.ammo-name a {
|
||||||
|
font-family:
|
||||||
|
system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans",
|
||||||
|
"Helvetica Neue", Arial, "Liberation Sans", sans-serif;
|
||||||
|
color: #283f75 !important;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.06rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--fg);
|
||||||
|
text-shadow: none;
|
||||||
|
/* remove glow completely */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ammo-name a:hover,
|
||||||
|
.ammo-name a:focus-visible {
|
||||||
|
color: #b0ffcf;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Subtle chip styles for flags */
|
||||||
|
.chip {
|
||||||
|
padding: .1rem .4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 255, 102, .25);
|
||||||
|
background: rgba(0, 255, 102, .06);
|
||||||
|
font-size: .8rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- ===== TOP BAR ===== -->
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
|
||||||
|
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation">
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="goons.html">Goons</a></li>
|
||||||
|
<li><a href="maps.html">Maps</a></li>
|
||||||
|
<li><a href="quests.html">Quests</a></li>
|
||||||
|
<li><a href="ammo.html" aria-current="page">Ammo</a></li>
|
||||||
|
<li><a href="traders.html">Traders</a></li>
|
||||||
|
<li><a href="pricewatch.html">Price Watch</a></li>
|
||||||
|
<li><a href="crafts.html">Craft Calculator</a></li>
|
||||||
|
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
|
||||||
|
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
|
||||||
|
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank"
|
||||||
|
rel="noopener">Wiki</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ===== PAGE CONTENT ===== -->
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="hero">Ammo & Ballistics</h1>
|
||||||
|
<!--<p class="page-title">Filter by caliber, sort by penetration, and quickly spot the right round for the job.</p>-->
|
||||||
|
|
||||||
|
<!-- (1) Toolbar -->
|
||||||
|
<section class="ammo-section" aria-labelledby="ammo-h2-tools">
|
||||||
|
<h2 id="ammo-h2-tools">1) Filters & Sorting</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="ammoSearch" type="text" placeholder="Search ammo..." aria-label="Search ammo by name" />
|
||||||
|
<label>
|
||||||
|
Caliber:
|
||||||
|
<select id="caliberSelect" aria-label="Filter by caliber">
|
||||||
|
<option value="">All calibers</option>
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Min Pen:
|
||||||
|
<input id="minPen" type="number" inputmode="numeric" pattern="[0-9]*" min="0" step="1"
|
||||||
|
placeholder="e.g., 35" aria-label="Minimum penetration value" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Sort:
|
||||||
|
<select id="sortSelect" aria-label="Sort ammo">
|
||||||
|
<option value="pen-desc">Penetration ⟱</option>
|
||||||
|
<option value="dmg-desc">Damage ⟱</option>
|
||||||
|
<option value="vel-desc">Velocity ⟱</option>
|
||||||
|
<option value="name-asc">Name ⟰</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="clearFilters" class="btn" type="button">Reset</button>
|
||||||
|
<span class="badge" id="ammoUpdated">Loading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:.35rem">
|
||||||
|
<!--Data refreshes about every 5 minutes on the API; client results are cached by your browser for this
|
||||||
|
session. API cache note -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- (2) Results -->
|
||||||
|
<section class="ammo-section" aria-labelledby="ammo-h2-results">
|
||||||
|
<h2 id="ammo-h2-results">2) Results</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="ammoTable" aria-label="Ammo list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sticky">Ammo</th>
|
||||||
|
<th class="sticky">Caliber</th>
|
||||||
|
<th class="sticky right">Pen</th>
|
||||||
|
<th class="sticky right">Dmg</th>
|
||||||
|
<th class="sticky right">Armor Dmg %</th>
|
||||||
|
<th class="sticky right">Frag %</th>
|
||||||
|
<th class="sticky right">Acc %</th>
|
||||||
|
<th class="sticky right">Recoil %</th>
|
||||||
|
<th class="sticky right">Vel (m/s)</th>
|
||||||
|
<th class="sticky">Flags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ammoTbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="muted">Loading ammo…</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="ammoStatus" class="muted" style="margin-top:.5rem;"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- HAMBURGER MENU SCRIPT -->
|
||||||
|
<script>
|
||||||
|
const btn = document.querySelector('.nav-toggle');
|
||||||
|
const nav = document.getElementById('primary-nav');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
btn.setAttribute('aria-expanded', !open);
|
||||||
|
nav.classList.toggle('is-open', !open);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Page logic -->
|
||||||
|
<script src="ammo.js" defer></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ===== DOM =====
|
||||||
|
const tbody = document.getElementById('ammoTbody');
|
||||||
|
const searchEl = document.getElementById('ammoSearch');
|
||||||
|
const caliberEl = document.getElementById('caliberSelect');
|
||||||
|
const minPenEl = document.getElementById('minPen');
|
||||||
|
const sortEl = document.getElementById('sortSelect'); // toolbar dropdown (kept)
|
||||||
|
const clearBtn = document.getElementById('clearFilters');
|
||||||
|
const updatedBadge = document.getElementById('ammoUpdated');
|
||||||
|
const statusEl = document.getElementById('ammoStatus');
|
||||||
|
const thead = document.querySelector('#ammoTable thead');
|
||||||
|
|
||||||
|
// ===== State =====
|
||||||
|
let ammo = []; // normalized rows
|
||||||
|
let calibers = []; // distinct list
|
||||||
|
let lastUpdated = '';
|
||||||
|
// Default: Name ascending
|
||||||
|
const sortState = { key: 'name', dir: 'asc' };
|
||||||
|
|
||||||
|
// Column mapping (by header index) -> sort key
|
||||||
|
// 0 Ammo, 1 Caliber, 2 Pen, 3 Dmg, 4 Armor Dmg %, 5 Frag %, 6 Acc %, 7 Recoil %, 8 Vel, 9 Flags
|
||||||
|
const headerKeys = [
|
||||||
|
'name', // 0
|
||||||
|
'caliber', // 1
|
||||||
|
'pen', // 2
|
||||||
|
'dmg', // 3
|
||||||
|
'armorDmgVal', // 4 (numeric)
|
||||||
|
'fragVal', // 5 (numeric)
|
||||||
|
'accVal', // 6 (numeric)
|
||||||
|
'recoilVal', // 7 (numeric)
|
||||||
|
'vel', // 8
|
||||||
|
null // 9 (Flags: not sortable)
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
function guessCaliberFromName(name = '') {
|
||||||
|
// e.g., "5.45x39mm BP gzh" -> "5.45x39mm"
|
||||||
|
const m = name.match(/^([0-9.]+x[0-9.]+mm|[.][0-9]+[\w.]*)/i);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPct(x) {
|
||||||
|
if (x === null || x === undefined) return null;
|
||||||
|
return Math.round(x * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSignedPct(x) {
|
||||||
|
if (x === null || x === undefined) return '';
|
||||||
|
const v = Math.round(x * 100);
|
||||||
|
return (v > 0 ? `+${v}` : `${v}`) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArmorPct(raw) {
|
||||||
|
// API often returns 0..100; if we see 0..1, convert to %
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
return raw > 1 ? Math.round(raw) : Math.round(raw * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmpValues(a, b) {
|
||||||
|
if (a == null && b == null) return 0;
|
||||||
|
if (a == null) return -1;
|
||||||
|
if (b == null) return 1;
|
||||||
|
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||||
|
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Normalization =====
|
||||||
|
/**
|
||||||
|
* Expected shape of /ammo-data.json:
|
||||||
|
* { fetchedAt, source, items: [ { id, name, shortName, iconLink, wikiLink, updated, properties: { __typename:"ItemPropertiesAmmo", ... } } ] }
|
||||||
|
*/
|
||||||
|
function normalize(raw) {
|
||||||
|
const items = Array.isArray(raw.items) ? raw.items : [];
|
||||||
|
|
||||||
|
const rows = items
|
||||||
|
.filter((it) => it && it.properties && it.properties.__typename === 'ItemPropertiesAmmo')
|
||||||
|
.map((it) => {
|
||||||
|
const p = it.properties || {};
|
||||||
|
const caliber = p.caliber || guessCaliberFromName(it.name);
|
||||||
|
|
||||||
|
const pen = p.penetrationPower ?? null;
|
||||||
|
const dmg = p.damage ?? null;
|
||||||
|
const armorDmgVal = asArmorPct(p.armorDamage); // numeric for sorting
|
||||||
|
const armorDmgPct = armorDmgVal; // display
|
||||||
|
const fragVal = p.fragmentationChance != null ? toPct(p.fragmentationChance) : null;
|
||||||
|
const fragPct = fragVal; // display
|
||||||
|
const accVal = p.accuracyModifier != null ? Math.round(p.accuracyModifier * 100) : null;
|
||||||
|
const accPct = p.accuracyModifier != null ? fmtSignedPct(p.accuracyModifier) : '';
|
||||||
|
const recoilVal = p.recoilModifier != null ? Math.round(p.recoilModifier * 100) : null;
|
||||||
|
const recoilPct = p.recoilModifier != null ? fmtSignedPct(p.recoilModifier) : '';
|
||||||
|
const vel = p.initialSpeed ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
short: it.shortName || it.name,
|
||||||
|
icon: it.iconLink,
|
||||||
|
wiki: it.wikiLink,
|
||||||
|
updated: it.updated || '',
|
||||||
|
caliber,
|
||||||
|
pen,
|
||||||
|
dmg,
|
||||||
|
armorDmgVal, armorDmgPct,
|
||||||
|
fragVal, fragPct,
|
||||||
|
accVal, accPct,
|
||||||
|
recoilVal, recoilPct,
|
||||||
|
vel,
|
||||||
|
tracer: p.tracer ? (p.tracerColor || 'Tracer') : '',
|
||||||
|
bleedLight: p.lightBleedModifier != null ? toPct(p.lightBleedModifier) : null,
|
||||||
|
bleedHeavy: p.heavyBleedModifier != null ? toPct(p.heavyBleedModifier) : null,
|
||||||
|
proj: p.projectileCount ?? 1
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const cals = Array.from(new Set(rows.map((r) => r.caliber).filter(Boolean)))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
lastUpdated = raw.fetchedAt || '';
|
||||||
|
|
||||||
|
return { rows, calibers: cals };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Rendering =====
|
||||||
|
function renderCalibers(list) {
|
||||||
|
caliberEl.innerHTML =
|
||||||
|
'<option value="">All calibers</option>' +
|
||||||
|
list.map((c) => `<option value="${c}">${c}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(rows) {
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="10" class="muted">No results. Try clearing filters.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = rows.map((r) => {
|
||||||
|
const flags = [r.tracer].filter(Boolean)
|
||||||
|
.map((s) => `<span class="chip">${s}</span>`).join(' ');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td data-label="Ammo">
|
||||||
|
<div class="ammo-name">
|
||||||
|
${r.icon ? `<img class="ammo-icon" src="${r.icon}" alt="">` : ''}
|
||||||
|
${r.wiki
|
||||||
|
? `<a href="${r.wiki}" target="_blank" rel="noopener">${r.name}</a>`
|
||||||
|
: `${r.name}`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Caliber">${r.caliber || ''}</td>
|
||||||
|
<td class="right" data-label="Pen">${r.pen ?? ''}</td>
|
||||||
|
<td class="right" data-label="Dmg">${r.dmg ?? ''}</td>
|
||||||
|
<td class="right" data-label="Armor Dmg %">${r.armorDmgPct ?? ''}</td>
|
||||||
|
<td class="right" data-label="Frag %">${r.fragPct ?? ''}</td>
|
||||||
|
<td class="right" data-label="Acc %">${r.accPct}</td>
|
||||||
|
<td class="right" data-label="Recoil %">${r.recoilPct}</td>
|
||||||
|
<td class="right" data-label="Vel (m/s)">${r.vel ?? ''}</td>
|
||||||
|
<td data-label="Flags">${flags}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Sorting =====
|
||||||
|
function sortRows(rows) {
|
||||||
|
const key = sortState.key;
|
||||||
|
const dir = sortState.dir === 'asc' ? 1 : -1;
|
||||||
|
return rows.slice().sort((a, b) => dir * cmpValues(a[key], b[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSort(key, dir) {
|
||||||
|
sortState.key = key;
|
||||||
|
sortState.dir = dir;
|
||||||
|
syncSortDropdown();
|
||||||
|
applyFilters(); // re-renders
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSortForKey(key) {
|
||||||
|
if (sortState.key === key) {
|
||||||
|
setSort(key, sortState.dir === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
// Default direction: strings asc, numbers desc
|
||||||
|
const stringKeys = new Set(['name', 'caliber']);
|
||||||
|
setSort(key, stringKeys.has(key) ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSortDropdown() {
|
||||||
|
// Map state -> dropdown value
|
||||||
|
const map = {
|
||||||
|
'name:asc': 'name-asc',
|
||||||
|
'pen:desc': 'pen-desc',
|
||||||
|
'dmg:desc': 'dmg-desc',
|
||||||
|
'vel:desc': 'vel-desc'
|
||||||
|
};
|
||||||
|
// If key not in dropdown, leave as-is (user can still change via header)
|
||||||
|
const k = `${sortState.key}:${sortState.dir}`;
|
||||||
|
if (map[k]) sortEl.value = map[k];
|
||||||
|
// Update header indicators
|
||||||
|
updateHeaderIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeaderIndicators() {
|
||||||
|
const ths = Array.from(thead.querySelectorAll('th'));
|
||||||
|
ths.forEach((th, i) => {
|
||||||
|
const key = headerKeys[i];
|
||||||
|
th.classList.remove('is-sorted-asc', 'is-sorted-desc');
|
||||||
|
th.setAttribute('aria-sort', 'none');
|
||||||
|
if (!key) return;
|
||||||
|
th.classList.add('is-sortable');
|
||||||
|
if (key === sortState.key) {
|
||||||
|
const cls = sortState.dir === 'asc' ? 'is-sorted-asc' : 'is-sorted-desc';
|
||||||
|
th.classList.add(cls);
|
||||||
|
th.setAttribute('aria-sort', sortState.dir === 'asc' ? 'ascending' : 'descending');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireHeaderSorting() {
|
||||||
|
const ths = Array.from(thead.querySelectorAll('th'));
|
||||||
|
ths.forEach((th, i) => {
|
||||||
|
const key = headerKeys[i];
|
||||||
|
if (!key) return; // skip Flags
|
||||||
|
th.classList.add('is-sortable');
|
||||||
|
th.title = 'Click to sort';
|
||||||
|
th.addEventListener('click', () => toggleSortForKey(key));
|
||||||
|
th.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSortForKey(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
th.tabIndex = 0; // keyboard focusable
|
||||||
|
});
|
||||||
|
updateHeaderIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Filtering + render pipeline =====
|
||||||
|
function applyFilters() {
|
||||||
|
const q = (searchEl.value || '').trim().toLowerCase();
|
||||||
|
const cal = (caliberEl.value || '').trim();
|
||||||
|
const minPen = parseInt(minPenEl.value || '0', 10);
|
||||||
|
|
||||||
|
let out = ammo.slice();
|
||||||
|
if (q) {
|
||||||
|
out = out.filter(
|
||||||
|
(r) =>
|
||||||
|
r.name.toLowerCase().includes(q) ||
|
||||||
|
r.short.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cal) out = out.filter((r) => r.caliber === cal);
|
||||||
|
if (!Number.isNaN(minPen) && minPen > 0) out = out.filter((r) => (r.pen ?? 0) >= minPen);
|
||||||
|
|
||||||
|
out = sortRows(out);
|
||||||
|
renderTable(out);
|
||||||
|
statusEl.textContent = `${out.length} of ${ammo.length} rounds shown`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireUI() {
|
||||||
|
searchEl.addEventListener('input', applyFilters);
|
||||||
|
caliberEl.addEventListener('change', applyFilters);
|
||||||
|
minPenEl.addEventListener('input', applyFilters);
|
||||||
|
|
||||||
|
// Keep dropdown sorter, but make default "name-asc"
|
||||||
|
sortEl.value = 'name-asc';
|
||||||
|
sortEl.addEventListener('change', () => {
|
||||||
|
switch (sortEl.value) {
|
||||||
|
case 'name-asc': setSort('name', 'asc'); break;
|
||||||
|
case 'pen-desc': setSort('pen', 'desc'); break;
|
||||||
|
case 'dmg-desc': setSort('dmg', 'desc'); break;
|
||||||
|
case 'vel-desc': setSort('vel', 'desc'); break;
|
||||||
|
default: setSort('name', 'asc'); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
searchEl.value = '';
|
||||||
|
caliberEl.value = '';
|
||||||
|
minPenEl.value = '';
|
||||||
|
sortEl.value = 'name-asc';
|
||||||
|
setSort('name', 'asc');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocal() {
|
||||||
|
const res = await fetch('/ammo-data.json', { cache: 'no-store' });
|
||||||
|
if (!res.ok) throw new Error(`Failed to load /ammo-data.json (HTTP ${res.status})`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
updatedBadge.textContent = 'Loading…';
|
||||||
|
|
||||||
|
const data = await loadLocal();
|
||||||
|
const { rows, calibers: cals } = normalize(data);
|
||||||
|
|
||||||
|
ammo = rows;
|
||||||
|
calibers = cals;
|
||||||
|
|
||||||
|
renderCalibers(calibers);
|
||||||
|
wireHeaderSorting();
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
updatedBadge.textContent = `Updated: ${lastUpdated || '—'}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
tbody.innerHTML =
|
||||||
|
`<tr><td colspan="10" class="muted">Failed to load ammo-data.json (${String(err)}).</td></tr>`;
|
||||||
|
updatedBadge.textContent = 'Update failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wireUI();
|
||||||
|
// Ensure default dropdown reflects our default sort
|
||||||
|
sortEl.value = 'name-asc';
|
||||||
|
init();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>ESCAPE FROM TARKOV COMPANION — Hideout Crafts</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<!-- Site-wide styles -->
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
|
||||||
|
<!-- Page-scoped cosmetics (kept light; mirrors your pc-* look) -->
|
||||||
|
<style>
|
||||||
|
.pc-section { border: 1px solid rgba(0,255,102,.25); padding: 1rem; margin: 1rem 0; box-shadow: 0 0 14px rgba(0,255,102,.08); }
|
||||||
|
.pc-row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
|
||||||
|
.pc-muted { color: #9aa0a6; }
|
||||||
|
.pc-badge { padding: .25rem .5rem; border-radius: 999px; background: rgba(0,255,102,.08); border: 1px solid rgba(0,255,102,.25); color: #a7f3d0; font-size: .85rem; white-space: nowrap; }
|
||||||
|
input[type="text"], input[type="number"], select, button { padding: .45rem .6rem; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
.pc-table-wrap { width: 100%; overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: .5rem; }
|
||||||
|
th, td { padding: .5rem .6rem; border-bottom: 1px solid rgba(0,255,102,.15); text-align: left; }
|
||||||
|
.right { text-align: right; }
|
||||||
|
.good { color: #22c55e; } .bad { color: #ef4444; }
|
||||||
|
|
||||||
|
/* Mobile card view (<= 680px), consistent with your pattern */
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
#hc-table thead { position: absolute; left: -9999px; height: 0; overflow: hidden; }
|
||||||
|
#hc-table, #hc-table tbody, #hc-table tr, #hc-table td { display: block; width: 100%; }
|
||||||
|
#hc-table tbody tr { border: 1px solid rgba(0,255,102,.25); border-radius: 8px; padding: .5rem .75rem; margin: .75rem 0; box-shadow: 0 0 10px rgba(0,255,102,.06); background: rgba(0,0,0,.2); }
|
||||||
|
#hc-table td { border: none; border-bottom: 1px solid rgba(0,255,102,.15); padding: .4rem 0; text-align: left !important; }
|
||||||
|
#hc-table td:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
|
#hc-table td::before { content: attr(data-label); display: block; font-size: .8rem; color: #9aa0a6; margin-bottom: .15rem; }
|
||||||
|
#hc-table td[data-label="Craft"] { font-weight: 600; color: #e5ffe5; font-size: 1rem; padding-top: 0; }
|
||||||
|
button { padding: .55rem .7rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Chart.js for grouped bar chart -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- ======= TOP BAR ======= -->
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
|
||||||
|
|
||||||
|
<!-- Hamburger -->
|
||||||
|
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Primary nav -->
|
||||||
|
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="goons.html">Goons</a></li>
|
||||||
|
<li><a href="maps.html">Maps</a></li>
|
||||||
|
<li><a href="quests.html">Quests</a></li>
|
||||||
|
<li><a href="ammo.html">Ammo</a></li>
|
||||||
|
<li><a href="traders.html">Traders</a></li>
|
||||||
|
<li><a href="pricewatch.html">Price Watch</a></li>
|
||||||
|
<li><a href="crafts.html">Craft Calculator</a></li>
|
||||||
|
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
|
||||||
|
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
|
||||||
|
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank" rel="noopener">Wiki</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ======= PAGE CONTENT ======= -->
|
||||||
|
<main class="content">
|
||||||
|
<h1>Hideout Crafts — Cost, Sell Value & Profit</h1>
|
||||||
|
<div class="pc-muted" style="margin:.25rem 0 1rem;">
|
||||||
|
Data from the public tarkov.dev GraphQL API (updates frequently).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1) Mode & Options -->
|
||||||
|
<section class="pc-section" aria-labelledby="hc-h2-mode">
|
||||||
|
<h2 id="hc-h2-mode">1) Mode & Options</h2>
|
||||||
|
|
||||||
|
<!-- Mode toggle (same pattern as your Price Watch) -->
|
||||||
|
<div class="pc-row" role="group" aria-label="Game mode">
|
||||||
|
<label><input type="radio" name="hc-mode" value="regular" checked> PvP (regular)</label>
|
||||||
|
<label><input type="radio" name="hc-mode" value="pve"> PvE</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-row">
|
||||||
|
<label>
|
||||||
|
Price metric:
|
||||||
|
<select id="hc-metric" aria-label="Price metric">
|
||||||
|
<option value="flea">Flea Market (sellFor)</option>
|
||||||
|
<option value="avg24hPrice">Avg 24h price</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Top N to chart:
|
||||||
|
<input id="hc-topn" type="number" min="5" max="200" value="30" aria-label="Top N by profit">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="pc-muted">Refreshes every ≈5 minutes.</span>
|
||||||
|
<button id="hc-refresh" class="btn" type="button">Refresh</button>
|
||||||
|
<span id="hc-updated" class="pc-badge">Data last updated: —</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2) Flea fee controls -->
|
||||||
|
<section class="pc-section" aria-labelledby="hc-h2-fee">
|
||||||
|
<h2 id="hc-h2-fee">2) Flea Fee (Approximation)</h2>
|
||||||
|
<div class="pc-row">
|
||||||
|
<label><input type="checkbox" id="hc-applyfee"> Apply flea fee to sell value</label>
|
||||||
|
<label>
|
||||||
|
Fee percentage:
|
||||||
|
<input id="hc-fee" type="number" min="0" max="30" step="0.5" value="10" aria-label="Flea fee percentage"> %
|
||||||
|
</label>
|
||||||
|
<span class="pc-muted">Tip: Real flea fees vary; this is a simple % of gross sell.</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3) Chart -->
|
||||||
|
<section class="pc-section" aria-labelledby="hc-h2-chart">
|
||||||
|
<h2 id="hc-h2-chart">3) Cost vs. Sell Value</h2>
|
||||||
|
<div class="pc-row">
|
||||||
|
<canvas id="hc-chart" height="160" aria-label="Craft costs and sell values chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="hc-status" class="pc-muted" role="status" aria-live="polite">Loading…</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4) Table -->
|
||||||
|
<section class="pc-section" aria-labelledby="hc-h2-table">
|
||||||
|
<h2 id="hc-h2-table">4) Details</h2>
|
||||||
|
<div class="pc-table-wrap">
|
||||||
|
<table id="hc-table" aria-label="Craft profitability">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Craft</th>
|
||||||
|
<th>Station</th>
|
||||||
|
<th class="right">Cost</th>
|
||||||
|
<th class="right">Sell (gross)</th>
|
||||||
|
<th class="right">Sell (net)</th>
|
||||||
|
<th class="right">Profit</th>
|
||||||
|
<th>Inputs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="hc-tbody">
|
||||||
|
<tr><td class="pc-muted" colspan="7">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pc-muted" style="margin-top:.5rem;">
|
||||||
|
<!--Prices per mode via <code>items(gameMode: …)</code>; flea price from <code>sellFor { source: "Flea Market" }</code> or fallback to <code>avg24hPrice</code>. [2](https://www.eft-ammo.com/flea-market-prices)-->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ===== Hamburger MENU SCRIPT ===== -->
|
||||||
|
<script>
|
||||||
|
const btn = document.querySelector('.nav-toggle');
|
||||||
|
const nav = document.getElementById('primary-nav');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
btn.setAttribute('aria-expanded', !open);
|
||||||
|
nav.classList.toggle('is-open', !open);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Page logic -->
|
||||||
|
<script src="crafts.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
|
})();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"fetchedAt":"2026-06-25T21:25:02+00:00","pvp":{"map":"Customs","timeText":"","lastSeenText":"","source":"https://www.goon-tracker.com/","debug":""},"pve":{"map":"Customs","timeText":"","lastSeenText":"","source":"https://www.goon-tracker.com/pvetracker","debug":""}}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
/* ===== Goons Status – page-scoped visuals only ===== */
|
||||||
|
|
||||||
|
/* Match your “card” look from other pages */
|
||||||
|
.gs-section {
|
||||||
|
border: 1px solid rgba(0,255,102,.25);
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
box-shadow: 0 0 14px rgba(0,255,102,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gs-row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
|
||||||
|
.muted { color: #9aa0a6; }
|
||||||
|
|
||||||
|
/* Pill/badge styles */
|
||||||
|
.chip {
|
||||||
|
padding: .2rem .5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(0,255,102,.25);
|
||||||
|
background: rgba(0,255,102,.08);
|
||||||
|
color: #a7f3d0;
|
||||||
|
font-size: .85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
.chip.stale {
|
||||||
|
border-color: rgba(255,194,0,.45);
|
||||||
|
background: rgba(255,194,0,.08);
|
||||||
|
color: #ffd889;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0,255,102,.08);
|
||||||
|
border: 1px solid rgba(0,255,102,.25);
|
||||||
|
color: #a7f3d0;
|
||||||
|
font-size: .85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map name */
|
||||||
|
.map-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .2rem .5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0,255,102,.25);
|
||||||
|
background: rgba(0,255,102,.08);
|
||||||
|
color: #283f75; /* your chosen accent color */
|
||||||
|
font-weight: 700;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
.pill-row { margin-bottom: .35rem; }
|
||||||
|
|
||||||
|
/* Cards & layout */
|
||||||
|
.gs-card { margin-bottom: .75rem; }
|
||||||
|
.gs-card h3 { margin: 0 0 .35rem 0; font-size: 1.05rem; }
|
||||||
|
|
||||||
|
.gs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header meta row */
|
||||||
|
.page-meta { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; }
|
||||||
|
.page-meta .spacer { flex: 1 1 auto; }
|
||||||
|
|
||||||
|
/* Tighten glow around updated badge for clearer text */
|
||||||
|
#gs-updated { text-shadow: none; }
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>ESCAPE FROM TARKOV COMPANION — Goons Status</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<!-- Global site styles -->
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<!-- Page-scoped styles for Goons page -->
|
||||||
|
<link rel="stylesheet" href="goons.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- ======= TOP BAR ======= -->
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
|
||||||
|
<!-- Hamburger -->
|
||||||
|
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<!-- Primary nav -->
|
||||||
|
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<!-- This page -->
|
||||||
|
<li><a href="goons.html" aria-current="page">Goons</a></li>
|
||||||
|
<li><a href="maps.html">Maps</a></li>
|
||||||
|
<li><a href="quests.html">Quests</a></li>
|
||||||
|
<li><a href="ammo.html">Ammo</a></li>
|
||||||
|
<li><a href="traders.html">Traders</a></li>
|
||||||
|
<li><a href="pricewatch.html">Price Watch</a></li>
|
||||||
|
<li><a href="crafts.html">Craft Calculator</a></li>
|
||||||
|
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
|
||||||
|
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
|
||||||
|
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank" rel="noopener">Wiki</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ======= PAGE CONTENT ======= -->
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="hero">Goons Locations</h1>
|
||||||
|
<p class="page-title">Locations are gathered from public trackers at <a href="https://www.goon-tracker.com" target="_blank" rel="noopener">PVP</a> and <a href="https://www.goon-tracker.com/pvetracker" target="_blank" rel="noopener">PVE</a> and refreshed every 5 minutes.</p>
|
||||||
|
|
||||||
|
<!-- Page meta row: stale badge + last updated -->
|
||||||
|
<div class="page-meta" style="margin-bottom:.5rem;">
|
||||||
|
<span id="gs-stale" class="chip" title="Data health badge" aria-live="polite">Checking freshness…</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span id="gs-updated" class="badge" title="From local /goons-status.json">Last updated: —</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PvP + PvE cards -->
|
||||||
|
<section class="gs-section" aria-labelledby="gs-h2">
|
||||||
|
<h2 id="gs-h2">Live Snapshot</h2>
|
||||||
|
<div class="gs-grid">
|
||||||
|
<div class="gs-card" id="card-pvp">
|
||||||
|
<h3>Last Seen (PvP)</h3>
|
||||||
|
<div id="pvp-body" class="muted">Loading PvP last-seen…</div>
|
||||||
|
</div>
|
||||||
|
<div class="gs-card" id="card-pve">
|
||||||
|
<h3>Last Seen (PvE)</h3>
|
||||||
|
<div id="pve-body" class="muted">Loading PvE last-seen…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin-top:.5rem;">
|
||||||
|
Sources: community tracker pages. Verify in-game; this is for convenience only.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- HAMBURGER MENU SCRIPT -->
|
||||||
|
<script>
|
||||||
|
const btn = document.querySelector('.nav-toggle');
|
||||||
|
const nav = document.getElementById('primary-nav');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
btn.setAttribute('aria-expanded', !open);
|
||||||
|
nav.classList.toggle('is-open', !open);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- PAGE LOGIC -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const UPDATED = document.getElementById('gs-updated');
|
||||||
|
const STALE = document.getElementById('gs-stale');
|
||||||
|
const PVP = document.getElementById('pvp-body');
|
||||||
|
const PVE = document.getElementById('pve-body');
|
||||||
|
|
||||||
|
// Consider data stale after N minutes
|
||||||
|
const STALE_MINUTES = 30;
|
||||||
|
|
||||||
|
function fmtDateLocal(iso) {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '—';
|
||||||
|
return d.toLocaleString();
|
||||||
|
} catch { return '—'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesDiffFromNow(iso) {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return Infinity;
|
||||||
|
return (Date.now() - d.getTime()) / 60000;
|
||||||
|
} catch { return Infinity; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStaleBadge(fetchedAtIso) {
|
||||||
|
const mins = minutesDiffFromNow(fetchedAtIso);
|
||||||
|
if (!isFinite(mins)) {
|
||||||
|
STALE.textContent = 'STALE (invalid time)';
|
||||||
|
STALE.classList.add('stale');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mins > STALE_MINUTES) {
|
||||||
|
STALE.textContent = `STALE (${Math.round(mins)} min old)`;
|
||||||
|
STALE.classList.add('stale');
|
||||||
|
} else {
|
||||||
|
STALE.textContent = 'Fresh';
|
||||||
|
STALE.classList.remove('stale');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPill(map) {
|
||||||
|
if (!map) return '<span class="muted">—</span>';
|
||||||
|
return `<span class="map-pill">${map}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlock(el, node, label) {
|
||||||
|
if (!node || !node.map) {
|
||||||
|
el.innerHTML = `<div class="muted">No recent ${label} data.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="pill-row">${mapPill(node.map)}</div>
|
||||||
|
${node.timeText ? `<div>Time: ${node.timeText}</div>` : ''}
|
||||||
|
${node.lastSeenText ? `<div class="muted" style="margin-top:.15rem;">Last seen: ${node.lastSeenText}</div>` : ''}
|
||||||
|
<div class="muted" style="margin-top:.35rem;">Source: ${node.source ? node.source.replace('https://','') : '—'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/goons-status.json', { cache: 'no-store' });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Last updated + stale badge
|
||||||
|
UPDATED.textContent = 'Last updated: ' + fmtDateLocal(data.fetchedAt || '');
|
||||||
|
setStaleBadge(data.fetchedAt || '');
|
||||||
|
|
||||||
|
// Cards
|
||||||
|
renderBlock(PVP, data.pvp, 'PvP');
|
||||||
|
renderBlock(PVE, data.pve, 'PvE');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
UPDATED.textContent = 'Last updated: —';
|
||||||
|
STALE.textContent = 'STALE (fetch failed)';
|
||||||
|
STALE.classList.add('stale');
|
||||||
|
PVP.textContent = 'Failed to load PvP last-seen.';
|
||||||
|
PVE.textContent = 'Failed to load PvE last-seen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 989 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 507 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 647 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>ESCAPE FROM TARKOV COMPANION</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
|
||||||
|
<!--HAMBURGER BUTTON-->
|
||||||
|
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation">
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="goons.html">Goons</a></li>
|
||||||
|
<li><a href="maps.html">Maps</a></li>
|
||||||
|
<li><a href="quests.html">Quests</a></li>
|
||||||
|
<li><a href="ammo.html">Ammo</a></li>
|
||||||
|
<li><a href="traders.html">Traders</a></li>
|
||||||
|
<li><a href="pricewatch.html" aria-current="page">Price Watch</a></li>
|
||||||
|
<li><a href="crafts.html">Craft Calculator</a></li>
|
||||||
|
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
|
||||||
|
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
|
||||||
|
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank"
|
||||||
|
rel="noopener">Wiki</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
<section id="Home">
|
||||||
|
<h1 class="hero" align="center">WELCOME TO THE ESCAPE FROM TARKOV COMPANION</h1>
|
||||||
|
<p align="center">Here you can find Goons locations, maps, and other tools for Escape From Tarkov.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<img src="https://i.redd.it/7shkp0qdaq8y.png" alt="Escape From Tarkov Companion Logo"
|
||||||
|
style="height: auto; max-width: 100%; display: block; margin-left: auto; margin-right: auto; border: 1px solid rgba(0,255,102,.25); box-shadow: 0 0 14px rgba(0,255,102,.25);">
|
||||||
|
<!--HAMBURGER MENU SCRIPT-->
|
||||||
|
<script>
|
||||||
|
const btn = document.querySelector('.nav-toggle');
|
||||||
|
const nav = document.getElementById('primary-nav');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
btn.setAttribute('aria-expanded', !open);
|
||||||
|
nav.classList.toggle('is-open', !open);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
maps.css — Page-specific styles for the Maps page
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/* Inputs: match your theme, scoped to this page */
|
||||||
|
.input {
|
||||||
|
background: #151924;
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid #2a3246;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.input::placeholder { color: #8fd8b3; }
|
||||||
|
|
||||||
|
/* Toolbar layout and small-screen tweaks */
|
||||||
|
.map-toolbar { gap: 10px; }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
/* Slightly trim the global content padding on phones so the
|
||||||
|
viewer sits closer to the title/tools. This overrides the
|
||||||
|
top padding defined in styles.css for small screens only. */
|
||||||
|
.content {
|
||||||
|
padding: 84px 14px 24px; /* was 96px top in styles.css */
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-toolbar .spacer { display: none; }
|
||||||
|
#mapSelect { width: 100%; }
|
||||||
|
|
||||||
|
.zoom-controls {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Map Viewer Container
|
||||||
|
- Desktop/tablet: comfortable bounded height
|
||||||
|
- Phone: fixed height using svh to ignore URL bar growth
|
||||||
|
========================================================= */
|
||||||
|
.map-viewer {
|
||||||
|
position: relative;
|
||||||
|
background: #0e131a;
|
||||||
|
border: 1px solid rgba(0, 255, 102, 0.18);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* Desktop/tablet defaults */
|
||||||
|
min-height: min(78vh, 900px);
|
||||||
|
max-height: 82vh;
|
||||||
|
|
||||||
|
/* Enable smooth gestures */
|
||||||
|
touch-action: none; /* allow custom pinch/pan */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phone-specific viewer height:
|
||||||
|
Use the "small viewport" to avoid oversized containers
|
||||||
|
when the mobile URL bar is visible. Provide fallbacks. */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.map-viewer {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
height: 65svh; /* preferred: stable with URL bar shown */
|
||||||
|
height: 65dvh; /* dynamic viewport fallback */
|
||||||
|
height: 65vh; /* legacy fallback */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Map Image
|
||||||
|
- Centered by default using translate(-50%, -50%)
|
||||||
|
- JS appends pan (x,y) + scale() after this base transform
|
||||||
|
========================================================= */
|
||||||
|
#mapImage {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
|
/* Base centering; JS keeps this and adds pan/zoom */
|
||||||
|
transform: translate3d(-50%, -50%, 0) scale(1);
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
|
||||||
|
will-change: transform;
|
||||||
|
image-rendering: auto;
|
||||||
|
|
||||||
|
/* Allow zooming beyond container bounds */
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: subtle grid overlay for orientation while zoomed */
|
||||||
|
.map-grid-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(0,255,102,0.06), rgba(0,255,102,0.06)),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 29px, rgba(0,255,102,0.06) 30px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 29px, rgba(0,255,102,0.06) 30px);
|
||||||
|
opacity: .2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom control button states */
|
||||||
|
.zoom-controls .btn[disabled] {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility helper (scoped copy in case the global one isn't loaded here) */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute !important;
|
||||||
|
height: 1px; width: 1px; overflow: hidden;
|
||||||
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
|
white-space: nowrap; clip-path: inset(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop cursor cues */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.map-viewer { cursor: grab; }
|
||||||
|
.map-viewer.is-panning { cursor: grabbing; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>ESCAPE FROM TARKOV COMPANION</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
|
<!-- Global site styles -->
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<!-- Page-specific styles for Maps -->
|
||||||
|
<link rel="stylesheet" href="maps.css" />
|
||||||
|
|
||||||
|
<!-- Preload default map for faster first paint -->
|
||||||
|
<link rel="preload" as="image" href="/images/TARKOV.webp" fetchpriority="high" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
|
||||||
|
|
||||||
|
<!-- HAMBURGER BUTTON -->
|
||||||
|
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation">
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="goons.html">Goons</a></li>
|
||||||
|
<li><a href="maps.html">Maps</a></li>
|
||||||
|
<li><a href="quests.html">Quests</a></li>
|
||||||
|
<li><a href="ammo.html">Ammo</a></li>
|
||||||
|
<li><a href="traders.html">Traders</a></li>
|
||||||
|
<li><a href="pricewatch.html">Price Watch</a></li>
|
||||||
|
<li><a href="crafts.html">Craft Calculator</a></li>
|
||||||
|
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
|
||||||
|
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
|
||||||
|
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank" rel="noopener">Wiki</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<h1 class="hero">Maps</h1>
|
||||||
|
<p class="page-title">Pinch or scroll to zoom • Drag to pan</p>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="trader-toolbar toolbar-flex map-toolbar" role="region" aria-label="Map tools">
|
||||||
|
<label for="mapSelect" class="visually-hidden">Select map</label>
|
||||||
|
<select id="mapSelect" class="input" aria-label="Select map">
|
||||||
|
<option value="TARKOV" selected>Tarkov (Global)</option>
|
||||||
|
<option value="CUSTOMS">Customs</option>
|
||||||
|
<option value="FACTORY">Factory</option>
|
||||||
|
<option value="GROUND_ZERO">Ground Zero</option>
|
||||||
|
<option value="INTERCHANGE">Interchange</option>
|
||||||
|
<option value="LABS">The Lab</option>
|
||||||
|
<option value="LABYRINTH">Labyrinth</option>
|
||||||
|
<option value="LIGHTHOUSE">Lighthouse</option>
|
||||||
|
<option value="RESERVE">Reserve</option>
|
||||||
|
<option value="SHORELINE">Shoreline</option>
|
||||||
|
<option value="STREETS">Streets</option>
|
||||||
|
<option value="WOODS">Woods</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<!-- Zoom controls -->
|
||||||
|
<div class="zoom-controls" role="group" aria-label="Zoom controls">
|
||||||
|
<button id="zoomOut" class="btn" type="button" aria-label="Zoom out">−</button>
|
||||||
|
<button id="zoomReset" class="btn" type="button" aria-label="Reset zoom">100%</button>
|
||||||
|
<button id="zoomIn" class="btn" type="button" aria-label="Zoom in">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Viewer -->
|
||||||
|
<section class="map-viewer" id="mapViewer" aria-label="Map viewer">
|
||||||
|
<img
|
||||||
|
id="mapImage"
|
||||||
|
src="/images/TARKOV.webp"
|
||||||
|
alt="Tarkov (Global) map"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority="high"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
<div class="map-grid-overlay" aria-hidden="true"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- HAMBURGER MENU SCRIPT -->
|
||||||
|
<script>
|
||||||
|
const btn = document.querySelector('.nav-toggle');
|
||||||
|
const nav = document.getElementById('primary-nav');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
btn.setAttribute('aria-expanded', !open);
|
||||||
|
nav.classList.toggle('is-open', !open);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- MAP SWITCH + ZOOM/PAN SCRIPT (desktop pan fix + no double-tap/dblclick) -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// ---------- Available maps ----------
|
||||||
|
const MAPS = [
|
||||||
|
{ id: 'TARKOV', label: 'Tarkov (Global)', file: '/images/TARKOV.webp' },
|
||||||
|
{ id: 'CUSTOMS', label: 'Customs', file: '/images/CUSTOMS.webp' },
|
||||||
|
{ id: 'FACTORY', label: 'Factory', file: '/images/FACTORY.webp' },
|
||||||
|
{ id: 'GROUND_ZERO', label: 'Ground Zero', file: '/images/GROUND_ZERO.webp' },
|
||||||
|
{ id: 'INTERCHANGE', label: 'Interchange', file: '/images/INTERCHANGE.webp' },
|
||||||
|
{ id: 'LABS', label: 'The Lab', file: '/images/LABS.webp' },
|
||||||
|
{ id: 'LABYRINTH', label: 'Labyrinth', file: '/images/LABYRINTH.webp' },
|
||||||
|
{ id: 'LIGHTHOUSE', label: 'Lighthouse', file: '/images/LIGHTHOUSE.webp' },
|
||||||
|
{ id: 'RESERVE', label: 'Reserve', file: '/images/RESERVE.webp' },
|
||||||
|
{ id: 'SHORELINE', label: 'Shoreline', file: '/images/SHORELINE.webp' },
|
||||||
|
{ id: 'STREETS', label: 'Streets', file: '/images/STREETS.webp' },
|
||||||
|
{ id: 'WOODS', label: 'Woods', file: '/images/WOODS.webp' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Elements ----------
|
||||||
|
const select = document.getElementById('mapSelect');
|
||||||
|
const viewer = document.getElementById('mapViewer');
|
||||||
|
const img = document.getElementById('mapImage');
|
||||||
|
const zoomInBtn = document.getElementById('zoomIn');
|
||||||
|
const zoomOutBtn = document.getElementById('zoomOut');
|
||||||
|
const zoomResetBtn = document.getElementById('zoomReset');
|
||||||
|
|
||||||
|
// Block native image drag on all browsers
|
||||||
|
img.addEventListener('dragstart', (e) => e.preventDefault());
|
||||||
|
viewer.addEventListener('dragstart', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
// ---------- Zoom/Pan state ----------
|
||||||
|
let baseScale = 1; // fit-to-viewer "contain" scale (recomputed per image)
|
||||||
|
let scale = 1; // current scale
|
||||||
|
let minScale = 1; // lower bound = baseScale
|
||||||
|
const maxScale = 5; // adjust if you want more/less max zoom
|
||||||
|
let x = 0, y = 0; // pan offsets, in CSS px relative to center
|
||||||
|
let isPanning = false;
|
||||||
|
let lastX = 0, lastY = 0;
|
||||||
|
|
||||||
|
// Pointer tracking for pinch
|
||||||
|
const pointers = new Map();
|
||||||
|
let initialPinchDistance = 0;
|
||||||
|
let pinchStart = null; // { x, y, scale }
|
||||||
|
|
||||||
|
// Keep transform origin centered
|
||||||
|
img.style.transformOrigin = '50% 50%';
|
||||||
|
|
||||||
|
// Center + pan/zoom
|
||||||
|
function applyTransform() {
|
||||||
|
img.style.transform =
|
||||||
|
`translate3d(-50%, -50%, 0) translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPan() {
|
||||||
|
const rect = viewer.getBoundingClientRect();
|
||||||
|
const naturalW = img.naturalWidth || 1;
|
||||||
|
const naturalH = img.naturalHeight || 1;
|
||||||
|
const displayW = naturalW * scale;
|
||||||
|
const displayH = naturalH * scale;
|
||||||
|
|
||||||
|
const maxOffsetX = Math.max(0, (displayW - rect.width) / 2);
|
||||||
|
const maxOffsetY = Math.max(0, (displayH - rect.height) / 2);
|
||||||
|
|
||||||
|
x = Math.min(maxOffsetX, Math.max(-maxOffsetX, x));
|
||||||
|
y = Math.min(maxOffsetY, Math.max(-maxOffsetY, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit image to viewer ("contain")
|
||||||
|
function computeBaseScale() {
|
||||||
|
const rect = viewer.getBoundingClientRect();
|
||||||
|
const iw = img.naturalWidth || 1;
|
||||||
|
const ih = img.naturalHeight || 1;
|
||||||
|
baseScale = Math.min(rect.width / iw, rect.height / ih);
|
||||||
|
if (!isFinite(baseScale) || baseScale <= 0) baseScale = 1;
|
||||||
|
minScale = baseScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
computeBaseScale();
|
||||||
|
scale = baseScale;
|
||||||
|
x = 0; y = 0;
|
||||||
|
clampPan();
|
||||||
|
applyTransform();
|
||||||
|
updateZoomUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateZoomUI() {
|
||||||
|
const pct = Math.round((scale / baseScale) * 100); // relative to fit
|
||||||
|
zoomResetBtn.textContent = `${pct}%`;
|
||||||
|
zoomOutBtn.disabled = scale <= minScale + 0.0001;
|
||||||
|
zoomInBtn.disabled = scale >= maxScale - 0.0001;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomAtPoint(factor, clientX, clientY) {
|
||||||
|
const rect = viewer.getBoundingClientRect();
|
||||||
|
const cx = clientX - rect.left - rect.width / 2;
|
||||||
|
const cy = clientY - rect.top - rect.height / 2;
|
||||||
|
|
||||||
|
const target = Math.min(maxScale, Math.max(minScale, scale * factor));
|
||||||
|
const scaleFactor = target / scale;
|
||||||
|
|
||||||
|
x = cx - (cx - x) * scaleFactor;
|
||||||
|
y = cy - (cy - y) * scaleFactor;
|
||||||
|
|
||||||
|
scale = target;
|
||||||
|
clampPan();
|
||||||
|
applyTransform();
|
||||||
|
updateZoomUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMap(id) {
|
||||||
|
const m = MAPS.find(m => m.id === id) || MAPS[0];
|
||||||
|
img.src = m.file;
|
||||||
|
img.alt = `${m.label} map`;
|
||||||
|
|
||||||
|
const doReset = () => resetView();
|
||||||
|
if ('decode' in img && typeof img.decode === 'function') {
|
||||||
|
img.decode().then(doReset).catch(doReset);
|
||||||
|
} else if (img.complete) {
|
||||||
|
doReset();
|
||||||
|
} else {
|
||||||
|
img.onload = doReset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map selection
|
||||||
|
select.addEventListener('change', () => setMap(select.value));
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
setMap(select.value || 'TARKOV');
|
||||||
|
|
||||||
|
// Handle viewer resize/rotation (keep relative zoom)
|
||||||
|
let resizeTimer = 0;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
const prevRatio = scale / baseScale;
|
||||||
|
computeBaseScale();
|
||||||
|
scale = Math.max(minScale, Math.min(maxScale, baseScale * prevRatio));
|
||||||
|
clampPan();
|
||||||
|
applyTransform();
|
||||||
|
updateZoomUI();
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Mouse/trackpad zoom -----
|
||||||
|
viewer.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
zoomAtPoint(factor, e.clientX, e.clientY);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// ----- Buttons (zoom around center) -----
|
||||||
|
zoomInBtn.addEventListener('click', () => {
|
||||||
|
const rect = viewer.getBoundingClientRect();
|
||||||
|
zoomAtPoint(1.15, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||||
|
});
|
||||||
|
zoomOutBtn.addEventListener('click', () => {
|
||||||
|
const rect = viewer.getBoundingClientRect();
|
||||||
|
zoomAtPoint(1 / 1.15, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||||
|
});
|
||||||
|
zoomResetBtn.addEventListener('click', resetView);
|
||||||
|
|
||||||
|
// ====== POINTER EVENTS (pan + pinch) ======
|
||||||
|
|
||||||
|
// POINTER DOWN
|
||||||
|
viewer.addEventListener('pointerdown', (e) => {
|
||||||
|
// On desktop, only pan with primary (left) mouse button
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
|
|
||||||
|
// Prevent native drag/select on desktop immediately
|
||||||
|
if (e.pointerType === 'mouse') e.preventDefault();
|
||||||
|
|
||||||
|
viewer.setPointerCapture(e.pointerId);
|
||||||
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
if (pointers.size === 1) {
|
||||||
|
// Single pointer → begin pan
|
||||||
|
isPanning = true;
|
||||||
|
viewer.classList.add('is-panning'); // for grab/grabbing cursor
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
} else if (pointers.size === 2) {
|
||||||
|
// Two pointers → pinch start
|
||||||
|
const pts = [...pointers.values()];
|
||||||
|
initialPinchDistance = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||||
|
pinchStart = { x, y, scale };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POINTER MOVE
|
||||||
|
viewer.addEventListener('pointermove', (e) => {
|
||||||
|
if (!pointers.has(e.pointerId)) return;
|
||||||
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
if (pointers.size === 1 && isPanning) {
|
||||||
|
// Prevent default drag/select/scroll on desktop while panning
|
||||||
|
if (e.pointerType === 'mouse') e.preventDefault();
|
||||||
|
|
||||||
|
const dx = e.clientX - lastX;
|
||||||
|
const dy = e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
x += dx; y += dy;
|
||||||
|
clampPan();
|
||||||
|
applyTransform();
|
||||||
|
} else if (pointers.size === 2 && pinchStart) {
|
||||||
|
// Pinch zoom
|
||||||
|
const pts = [...pointers.values()];
|
||||||
|
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||||
|
const centerX = (pts[0].x + pts[1].x) / 2;
|
||||||
|
const centerY = (pts[0].y + pts[1].y) / 2;
|
||||||
|
|
||||||
|
const newScale = Math.min(maxScale, Math.max(minScale, pinchStart.scale * (dist / initialPinchDistance)));
|
||||||
|
|
||||||
|
const rect = viewer.getBoundingClientRect();
|
||||||
|
const cx = centerX - rect.left - rect.width / 2;
|
||||||
|
const cy = centerY - rect.top - rect.height / 2;
|
||||||
|
|
||||||
|
const scaleFactor = newScale / scale;
|
||||||
|
x = cx - (cx - x) * scaleFactor;
|
||||||
|
y = cy - (cy - y) * scaleFactor;
|
||||||
|
scale = newScale;
|
||||||
|
|
||||||
|
clampPan();
|
||||||
|
applyTransform();
|
||||||
|
updateZoomUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POINTER UP / CANCEL / LEAVE
|
||||||
|
function endPointer(e) {
|
||||||
|
if (pointers.has(e.pointerId)) pointers.delete(e.pointerId);
|
||||||
|
if (pointers.size < 2) {
|
||||||
|
initialPinchDistance = 0;
|
||||||
|
pinchStart = null;
|
||||||
|
}
|
||||||
|
if (pointers.size === 0) {
|
||||||
|
isPanning = false;
|
||||||
|
viewer.classList.remove('is-panning'); // restore cursor
|
||||||
|
}
|
||||||
|
viewer.releasePointerCapture?.(e.pointerId);
|
||||||
|
}
|
||||||
|
viewer.addEventListener('pointerup', endPointer);
|
||||||
|
viewer.addEventListener('pointercancel', endPointer);
|
||||||
|
viewer.addEventListener('pointerleave', endPointer);
|
||||||
|
|
||||||
|
// Prevent native gesture zoom (iOS Safari)
|
||||||
|
viewer.addEventListener('gesturestart', (e) => e.preventDefault());
|
||||||
|
viewer.addEventListener('gesturechange', (e) => e.preventDefault());
|
||||||
|
viewer.addEventListener('gestureend', (e) => e.preventDefault());
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>ESCAPE FROM TARKOV COMPANION — Price Check</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<!-- Site-wide styles -->
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
|
||||||
|
<!-- Page-scoped styles kept inline (visuals only) -->
|
||||||
|
<style>
|
||||||
|
/* ===== Base Helpers ===== */
|
||||||
|
.pc-section { border: 1px solid rgba(0,255,102,.25); padding: 1rem; margin: 1rem 0; box-shadow: 0 0 14px rgba(0,255,102,.08); }
|
||||||
|
.pc-row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
|
||||||
|
.pc-muted { color: #9aa0a6; }
|
||||||
|
.pc-list { margin: .5rem 0; padding-left: 1rem; }
|
||||||
|
.pc-list li { cursor: pointer; padding: .25rem 0; }
|
||||||
|
.pc-list li:hover { background: rgba(0,255,102,.08); }
|
||||||
|
.pc-good { color: #22c55e; }
|
||||||
|
.pc-warn { color: #f59e0b; }
|
||||||
|
.pc-bad { color: #ef4444; }
|
||||||
|
input[type="text"], input[type="number"], select, button { padding: .45rem .6rem; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: .5rem; }
|
||||||
|
th, td { padding: .5rem .6rem; border-bottom: 1px solid rgba(0,255,102,.15); text-align: left; }
|
||||||
|
.right { text-align: right; }
|
||||||
|
#pc-selected strong { color: #00ff66; }
|
||||||
|
/* Prevent table from blowing out layout on small screens */
|
||||||
|
.pc-table-wrap { width: 100%; overflow-x: auto; }
|
||||||
|
|
||||||
|
/* ===== Mobile Card View (<= 680px) ===== */
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
/* Hide header; we’ll show labels via data-label on cells */
|
||||||
|
#pc-table thead {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#pc-table, #pc-table tbody, #pc-table tr, #pc-table td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* Each row becomes a card */
|
||||||
|
#pc-table tbody tr {
|
||||||
|
border: 1px solid rgba(0,255,102,.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
margin: .75rem 0;
|
||||||
|
box-shadow: 0 0 10px rgba(0,255,102,.06);
|
||||||
|
background: rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
/* Stacked cells with inlined labels */
|
||||||
|
#pc-table td {
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid rgba(0,255,102,.15);
|
||||||
|
padding: .4rem 0;
|
||||||
|
text-align: left !important; /* override .right in stacked view */
|
||||||
|
}
|
||||||
|
#pc-table td:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
|
#pc-table td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
display: block;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: #9aa0a6;
|
||||||
|
margin-bottom: .15rem;
|
||||||
|
}
|
||||||
|
/* Actions: keep buttons on one line and right-aligned */
|
||||||
|
#pc-table td[data-label="Actions"] {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: .5rem;
|
||||||
|
}
|
||||||
|
/* Emphasize item name at the top of card */
|
||||||
|
#pc-table td[data-label="Item"] {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e5ffe5;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
/* Larger tap targets */
|
||||||
|
button { padding: .55rem .7rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Ultra-narrow phones (<= 420px) ===== */
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
#pc-table td[data-label="24h Low"],
|
||||||
|
#pc-table td[data-label="24h High"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small badge style */
|
||||||
|
.pc-badge {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0,255,102,.08);
|
||||||
|
border: 1px solid rgba(0,255,102,.25);
|
||||||
|
color: #a7f3d0;
|
||||||
|
font-size: .85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout hardening */
|
||||||
|
.pc-row > * { min-width: 0; }
|
||||||
|
#pc-updated { white-space: normal; overflow-wrap: anywhere; min-width: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
#pc-countdown, #pc-updated { flex-basis: 100%; order: 10; margin-top: .25rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- ======= TOP BAR ======= -->
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
|
||||||
|
|
||||||
|
<!-- Hamburger -->
|
||||||
|
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Primary nav -->
|
||||||
|
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="goons.html">Goons</a></li>
|
||||||
|
<li><a href="maps.html">Maps</a></li>
|
||||||
|
<li><a href="quests.html">Quests</a></li>
|
||||||
|
<li><a href="ammo.html">Ammo</a></li>
|
||||||
|
<li><a href="traders.html">Traders</a></li>
|
||||||
|
<li><a href="pricewatch.html" aria-current="page">Price Watch</a></li>
|
||||||
|
<li><a href="crafts.html">Craft Calculator</a></li>
|
||||||
|
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
|
||||||
|
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
|
||||||
|
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank" rel="noopener">Wiki</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ======= PAGE CONTENT ======= -->
|
||||||
|
<main class="content">
|
||||||
|
<h1>Price Check & Alerts</h1>
|
||||||
|
<br>
|
||||||
|
Get an alert when an item's price crosses your target!
|
||||||
|
|
||||||
|
<!-- 1) Mode & Search -->
|
||||||
|
<section class="pc-section" aria-labelledby="pc-h2-search">
|
||||||
|
<h2 id="pc-h2-search">1) Mode & Search</h2>
|
||||||
|
|
||||||
|
<div class="pc-row" role="group" aria-label="Game mode">
|
||||||
|
<label><input type="radio" name="pc-mode" value="regular" checked> PvP (regular)</label>
|
||||||
|
<label><input type="radio" name="pc-mode" value="pve"> PvE</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-row">
|
||||||
|
<input id="pc-search" type="text" placeholder="Item name..." size="28" aria-label="Item name">
|
||||||
|
<!-- Styled like Quests buttons via .btn from styles.css -->
|
||||||
|
<button id="pc-btnSearch" class="btn" type="button">Search</button>
|
||||||
|
<span class="pc-muted">Local data updates every ≈5 minutes (cron).</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul id="pc-results" class="pc-list" aria-live="polite"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2) Add to watch list -->
|
||||||
|
<section class="pc-section" aria-labelledby="pc-h2-add">
|
||||||
|
<h2 id="pc-h2-add">2) Add to Watch List</h2>
|
||||||
|
<div id="pc-selected" class="pc-muted">No item selected.</div>
|
||||||
|
<div id="pc-prices" class="pc-muted"></div>
|
||||||
|
|
||||||
|
<div class="pc-row" style="margin-top:.75rem;">
|
||||||
|
<label>
|
||||||
|
Notify when
|
||||||
|
<select id="pc-direction" aria-label="Notify direction">
|
||||||
|
<option value="above">at or above</option>
|
||||||
|
<option value="below">at or below</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Target:
|
||||||
|
<input id="pc-target" type="number" min="0" step="1" placeholder="e.g., 125000" aria-label="Target price (Rubles)">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Keep the consistent button look -->
|
||||||
|
<button id="pc-add" class="btn" disabled>Add to watch list</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-muted" style="margin-top:.5rem;">
|
||||||
|
Tip: You can add multiple items. Each entry will alert with a sound + popup and then pause itself.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3) Watch list -->
|
||||||
|
<section class="pc-section" aria-labelledby="pc-h2-watch">
|
||||||
|
<h2 id="pc-h2-watch">3) Watch List</h2>
|
||||||
|
|
||||||
|
<div class="pc-row" style="gap:.75rem; align-items:center;">
|
||||||
|
<span style="flex:1 1 auto;"></span>
|
||||||
|
<span id="pc-countdown" class="pc-muted" aria-live="polite"></span>
|
||||||
|
<span id="pc-updated" class="pc-badge" title="From static file Last-Modified headers">
|
||||||
|
Data last updated: —
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-table-wrap">
|
||||||
|
<table id="pc-table" aria-label="Watch list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th class="right">Current</th>
|
||||||
|
<th class="right">24h Low</th>
|
||||||
|
<th class="right">24h High</th>
|
||||||
|
<th>Condition</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pc-tbody">
|
||||||
|
<tr><td colspan="8" class="pc-muted">No items being watched.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pc-status" class="pc-muted" style="margin-top:.5rem;"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== Divider before Top Spreads ===== -->
|
||||||
|
<hr style="margin:2.5rem 0; border-color: rgba(0,255,102,.25);">
|
||||||
|
|
||||||
|
<!-- ===== Top Spreads & Trader → Flea Flips ===== -->
|
||||||
|
<section class="pc-section" aria-labelledby="ts-h2">
|
||||||
|
<h2 id="ts-h2">Top Spreads & Trader → Flea Flips</h2>
|
||||||
|
|
||||||
|
<div class="pc-row controls" style="margin-bottom:.5rem;">
|
||||||
|
<button id="toggle-spread" class="btn is-active" aria-pressed="true">
|
||||||
|
Flea Spreads (High − Low)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="toggle-trader" class="btn" aria-pressed="false">
|
||||||
|
Trader → Flea Flips
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span id="last-updated" class="pc-muted" style="margin-left:auto;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="pc-muted" role="status" aria-live="polite">
|
||||||
|
Loading price data…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pc-table-wrap">
|
||||||
|
<table id="spread-table" aria-label="Top spreads and flips" hidden>
|
||||||
|
<thead id="spread-thead"></thead>
|
||||||
|
<tbody id="spread-tbody"></tbody>
|
||||||
|
<caption id="spread-caption" class="pc-muted" style="caption-side: bottom; padding-top:.5rem;">
|
||||||
|
Data: tarkov.dev
|
||||||
|
</caption>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ===== Hamburger MENU SCRIPT ===== -->
|
||||||
|
<script>
|
||||||
|
const btn = document.querySelector('.nav-toggle');
|
||||||
|
const nav = document.getElementById('primary-nav');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
btn.setAttribute('aria-expanded', !open);
|
||||||
|
nav.classList.toggle('is-open', !open);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ===== Page Logic ===== -->
|
||||||
|
<!-- Your existing Price Check logic -->
|
||||||
|
<script src="pricewatch.js"></script>
|
||||||
|
<!-- Top Spreads & Trader Flips logic (the file we built together) -->
|
||||||
|
<script src="top_spreads.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
|
||||||
|
/* pricewatch.js — Price Watch logic (local JSON version)
|
||||||
|
- Enter in item input => search + focus target
|
||||||
|
- Enter in target input => add to watch (if enabled)
|
||||||
|
- Unified "Data last updated" from PvP/PvE files
|
||||||
|
- 5-min cron countdown & polling
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/***** Small: hamburger toggle *****/
|
||||||
|
const navBtn = document.querySelector('.nav-toggle');
|
||||||
|
const navEl = document.getElementById('primary-nav');
|
||||||
|
if (navBtn && navEl) {
|
||||||
|
navBtn.addEventListener('click', () => {
|
||||||
|
const open = navBtn.getAttribute('aria-expanded') === 'true';
|
||||||
|
navBtn.setAttribute('aria-expanded', String(!open));
|
||||||
|
navEl.classList.toggle('is-open', !open);
|
||||||
|
document.body.classList.toggle('nav-open', !open);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/***** DOM *****/
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
const resultsEl = $('#pc-results');
|
||||||
|
const selectedEl = $('#pc-selected');
|
||||||
|
const pricesEl = $('#pc-prices');
|
||||||
|
const statusEl = $('#pc-status');
|
||||||
|
const tbodyEl = $('#pc-tbody');
|
||||||
|
const addBtn = $('#pc-add');
|
||||||
|
const countdownEl = $('#pc-countdown');
|
||||||
|
const updatedEl = $('#pc-updated');
|
||||||
|
|
||||||
|
/***** Config *****/
|
||||||
|
const CRON_INTERVAL_MS = 5 * 60 * 1000; // every 5 minutes
|
||||||
|
const CRON_GRACE_MS = 7 * 1000; // allow cron to finish before we poll
|
||||||
|
const CRON_POLL_STEP_MS = 2000; // poll every 2s
|
||||||
|
const CRON_POLL_MAX_MS = 45 * 1000; // poll up to 45s after expected tick
|
||||||
|
|
||||||
|
/***** State *****/
|
||||||
|
let searchPick = null; // { id, name, shortName, mode, ... }
|
||||||
|
let state = {
|
||||||
|
cronIntervalMs: CRON_INTERVAL_MS,
|
||||||
|
cronGraceMs: CRON_GRACE_MS,
|
||||||
|
cronPollStepMs: CRON_POLL_STEP_MS,
|
||||||
|
cronPollMaxMs: CRON_POLL_MAX_MS,
|
||||||
|
serverOffsetMs: 0, // serverTime - clientTime (ms)
|
||||||
|
lastKnownUpdatedMs: 0, // newest Last-Modified we've seen (ms epoch)
|
||||||
|
nextTickAt: 0, // ms epoch of next expected cron run (LM + cron interval)
|
||||||
|
cronAlarmId: null,
|
||||||
|
countdownTimer: null,
|
||||||
|
isRefreshing: false,
|
||||||
|
watch: [] // { id, name, mode, direction, target, triggered, last:{curr,low,high} }
|
||||||
|
};
|
||||||
|
|
||||||
|
/***** Audio *****/
|
||||||
|
function playBeep() {
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const o = ctx.createOscillator();
|
||||||
|
const g = ctx.createGain();
|
||||||
|
o.type = 'sine';
|
||||||
|
o.frequency.value = 700;
|
||||||
|
o.connect(g);
|
||||||
|
g.connect(ctx.destination);
|
||||||
|
g.gain.setValueAtTime(0.25, ctx.currentTime);
|
||||||
|
o.start();
|
||||||
|
o.stop(ctx.currentTime + 0.25);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/***** Utils *****/
|
||||||
|
function getMode() {
|
||||||
|
const el = document.querySelector('input[name="pc-mode"]:checked');
|
||||||
|
return el ? el.value : 'regular';
|
||||||
|
}
|
||||||
|
function fmtRubles(n) {
|
||||||
|
if (n === null || n === undefined) return 'n/a';
|
||||||
|
return Number(n).toLocaleString(undefined) + ' Rubles';
|
||||||
|
}
|
||||||
|
function resolveCurrent(it) {
|
||||||
|
// Prefer lastLowPrice; then low24h; then avg24h
|
||||||
|
return it?.lastLowPrice ?? it?.low24hPrice ?? it?.avg24hPrice ?? null;
|
||||||
|
}
|
||||||
|
function keyOf(entry) { return `${entry.mode}:${entry.id}`; }
|
||||||
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
/***** Local data helpers *****/
|
||||||
|
async function getLocalData(mode) {
|
||||||
|
const url = mode === 'pve' ? '/items_pve.json' : '/items_pvp.json';
|
||||||
|
// Per-minute cache-bust
|
||||||
|
const v = Math.floor(Date.now() / 60000);
|
||||||
|
const res = await fetch(`${url}?v=${v}`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) throw new Error(`Failed to load ${url}`);
|
||||||
|
return res.json(); // returns an array
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTP header helpers (Last-Modified + Date for server clock) ----
|
||||||
|
async function fetchLastModified(url) {
|
||||||
|
try {
|
||||||
|
const head = await fetch(url, { method: 'HEAD' });
|
||||||
|
const lm = head.headers.get('Last-Modified');
|
||||||
|
if (lm) return new Date(lm);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const get = await fetch(url + '?stamp=' + Date.now(), { cache: 'no-store' });
|
||||||
|
const lm = get.headers.get('Last-Modified');
|
||||||
|
if (lm) return new Date(lm);
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async function fetchServerDate(urlForHeaders = '/items_pvp.json') {
|
||||||
|
try {
|
||||||
|
const res = await fetch(urlForHeaders, { method: 'HEAD' });
|
||||||
|
const srv = res.headers.get('Date');
|
||||||
|
return srv ? new Date(srv) : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
async function syncServerClock(urlForHeaders = '/items_pvp.json') {
|
||||||
|
const serverDate = await fetchServerDate(urlForHeaders);
|
||||||
|
if (serverDate) {
|
||||||
|
state.serverOffsetMs = serverDate.getTime() - Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function serverNowMs() { return Date.now() + state.serverOffsetMs; }
|
||||||
|
|
||||||
|
// Newest LM across PvP/PvE and render the badge; return ms epoch or 0 if unknown
|
||||||
|
async function readAndRenderLastUpdated() {
|
||||||
|
const [pvpDate, pveDate] = await Promise.all([
|
||||||
|
fetchLastModified('/items_pvp.json'),
|
||||||
|
fetchLastModified('/items_pve.json')
|
||||||
|
]);
|
||||||
|
const newest = (!pvpDate && !pveDate)
|
||||||
|
? null
|
||||||
|
: (pvpDate && pveDate ? (pvpDate > pveDate ? pvpDate : pveDate) : (pvpDate || pveDate));
|
||||||
|
const lmText = newest ? newest.toLocaleString() : '—';
|
||||||
|
updatedEl.textContent = `Data last updated: ${lmText}`;
|
||||||
|
updatedEl.title = newest ? newest.toISOString() : '';
|
||||||
|
return newest ? newest.getTime() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextCronAlarm() {
|
||||||
|
if (!state.lastKnownUpdatedMs) {
|
||||||
|
// Fallback: align to next 5-min boundary from server time
|
||||||
|
const now = serverNowMs();
|
||||||
|
const nextBoundary = Math.ceil(now / state.cronIntervalMs) * state.cronIntervalMs;
|
||||||
|
state.nextTickAt = nextBoundary;
|
||||||
|
} else {
|
||||||
|
state.nextTickAt = state.lastKnownUpdatedMs + state.cronIntervalMs;
|
||||||
|
}
|
||||||
|
// Alarm just past expected tick to give cron time to run
|
||||||
|
const fireAt = state.nextTickAt + state.cronGraceMs;
|
||||||
|
const delay = Math.max(0, fireAt - serverNowMs());
|
||||||
|
if (state.cronAlarmId) clearTimeout(state.cronAlarmId);
|
||||||
|
state.cronAlarmId = setTimeout(handleCronWindow, delay);
|
||||||
|
updateCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCronWindow() {
|
||||||
|
// Poll for Last-Modified to advance beyond lastKnownUpdatedMs
|
||||||
|
const prev = state.lastKnownUpdatedMs || 0;
|
||||||
|
const start = serverNowMs();
|
||||||
|
let advanced = false;
|
||||||
|
while (serverNowMs() - start <= state.cronPollMaxMs) {
|
||||||
|
const nowLm = await readAndRenderLastUpdated(); // also updates the badge
|
||||||
|
if (nowLm && nowLm > prev) {
|
||||||
|
state.lastKnownUpdatedMs = nowLm;
|
||||||
|
advanced = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(state.cronPollStepMs);
|
||||||
|
}
|
||||||
|
// Whether we saw the change or timed out, refresh table once.
|
||||||
|
await refreshAll().catch(() => {});
|
||||||
|
// Recompute nextTick and schedule again
|
||||||
|
if (!advanced && state.lastKnownUpdatedMs < prev) state.lastKnownUpdatedMs = prev;
|
||||||
|
if (!state.lastKnownUpdatedMs) state.lastKnownUpdatedMs = await readAndRenderLastUpdated();
|
||||||
|
scheduleNextCronAlarm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/***** Countdown helpers (to next cron run) *****/
|
||||||
|
function updateCountdown() {
|
||||||
|
const now = serverNowMs();
|
||||||
|
if (!state.nextTickAt) { countdownEl.textContent = ''; return; }
|
||||||
|
let ms = state.nextTickAt - now;
|
||||||
|
if (ms <= 0) {
|
||||||
|
const missed = Math.ceil((-ms) / state.cronIntervalMs);
|
||||||
|
state.nextTickAt += missed * state.cronIntervalMs;
|
||||||
|
ms = state.nextTickAt - now;
|
||||||
|
}
|
||||||
|
const secs = Math.max(0, Math.ceil(ms / 1000));
|
||||||
|
countdownEl.textContent = `Next refresh in: ${secs}s`;
|
||||||
|
}
|
||||||
|
function startCountdown() {
|
||||||
|
if (state.countdownTimer) clearInterval(state.countdownTimer);
|
||||||
|
updateCountdown();
|
||||||
|
state.countdownTimer = setInterval(updateCountdown, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/***** Search *****/
|
||||||
|
async function doSearch() {
|
||||||
|
const name = $('#pc-search').value.trim().toLowerCase();
|
||||||
|
if (!name) return;
|
||||||
|
const mode = getMode();
|
||||||
|
selectedEl.textContent = 'Searching...';
|
||||||
|
pricesEl.textContent = '';
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
searchPick = null;
|
||||||
|
addBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const items = await getLocalData(mode);
|
||||||
|
const results = items.filter(it =>
|
||||||
|
(it.name || '').toLowerCase().includes(name) ||
|
||||||
|
(it.shortName || '').toLowerCase().includes(name)
|
||||||
|
);
|
||||||
|
showSearchResults(results, mode);
|
||||||
|
} catch (e) {
|
||||||
|
selectedEl.textContent = 'Error: local data not found. Make sure cron has generated the JSON files.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSearchResults(items, mode) {
|
||||||
|
if (!items.length) {
|
||||||
|
selectedEl.textContent = 'No items found.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.length === 1) {
|
||||||
|
pickItem(items[0], mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedEl.textContent = `Multiple results (${items.length}). Click one below.`;
|
||||||
|
resultsEl.innerHTML = items.map(it => {
|
||||||
|
const cur = resolveCurrent(it);
|
||||||
|
return `<li data-id="${it.id}" data-name="${it.name}" data-mode="${mode}"
|
||||||
|
data-lp="${it.lastLowPrice ?? ''}" data-low="${it.low24hPrice ?? ''}" data-high="${it.high24hPrice ?? ''}">
|
||||||
|
<strong>${it.name}</strong> (${it.shortName ?? '—'}) —
|
||||||
|
current: ${fmtRubles(cur)},
|
||||||
|
24h L/H: ${fmtRubles(it.low24hPrice)} / ${fmtRubles(it.high24hPrice)}
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickItem(it, mode) {
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
searchPick = { ...it, mode };
|
||||||
|
selectedEl.innerHTML = `Selected: <strong>${it.name}</strong> (${it.id}) [${mode}]`;
|
||||||
|
const cur = resolveCurrent(it);
|
||||||
|
pricesEl.innerHTML =
|
||||||
|
`Current: <strong>${fmtRubles(cur)}</strong><br>` +
|
||||||
|
`24h Low / High: ${fmtRubles(it.low24hPrice)} / ${fmtRubles(it.high24hPrice)}`;
|
||||||
|
addBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/***** Watch list table *****/
|
||||||
|
function renderWatchTable() {
|
||||||
|
if (!state.watch.length) {
|
||||||
|
tbodyEl.innerHTML = `<tr><td colspan="8" class="pc-muted">No items being watched.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbodyEl.innerHTML = state.watch.map(w => {
|
||||||
|
const currTxt = w.last?.curr != null ? fmtRubles(w.last.curr) : '—';
|
||||||
|
const lowTxt = w.last?.low != null ? fmtRubles(w.last.low ) : '—';
|
||||||
|
const highTxt = w.last?.high != null ? fmtRubles(w.last.high) : '—';
|
||||||
|
const status = w.triggered ? 'Triggered' : 'Watching';
|
||||||
|
const key = keyOf(w);
|
||||||
|
const cond = `${w.direction} ${fmtRubles(w.target)}`;
|
||||||
|
return `
|
||||||
|
<tr id="row-${w.mode}-${w.id}">
|
||||||
|
<td data-label="Item">${w.name}</td>
|
||||||
|
<td data-label="Mode">${w.mode}</td>
|
||||||
|
<td data-label="Current" class="right">${currTxt}</td>
|
||||||
|
<td data-label="24h Low" class="right">${lowTxt}</td>
|
||||||
|
<td data-label="24h High" class="right">${highTxt}</td>
|
||||||
|
<td data-label="Condition">${cond}</td>
|
||||||
|
<td data-label="Status">${status}</td>
|
||||||
|
<td data-label="Actions">
|
||||||
|
<button data-action="resume" data-key="${key}" class="btn">Resume</button>
|
||||||
|
<button data-action="remove" data-key="${key}" class="btn pc-bad">Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/***** Refresh helpers using local JSON *****/
|
||||||
|
async function refreshSpecific(entries) {
|
||||||
|
const byMode = new Set(entries.map(e => e.mode));
|
||||||
|
const snapshots = {};
|
||||||
|
for (const mode of byMode) snapshots[mode] = await getLocalData(mode);
|
||||||
|
const maps = {};
|
||||||
|
for (const mode of Object.keys(snapshots)) {
|
||||||
|
maps[mode] = new Map(snapshots[mode].map(it => [it.id, it]));
|
||||||
|
}
|
||||||
|
for (const w of entries) {
|
||||||
|
const it = maps[w.mode].get(w.id);
|
||||||
|
if (it) applyMetrics(w, it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
if (state.isRefreshing) return;
|
||||||
|
state.isRefreshing = true;
|
||||||
|
try {
|
||||||
|
const active = state.watch.filter(w => !w.triggered);
|
||||||
|
const neededModes = new Set(active.map(w => w.mode));
|
||||||
|
if (searchPick) neededModes.add(searchPick.mode);
|
||||||
|
const snapshots = {};
|
||||||
|
for (const mode of neededModes) snapshots[mode] = await getLocalData(mode);
|
||||||
|
const maps = {};
|
||||||
|
for (const mode of Object.keys(snapshots)) {
|
||||||
|
maps[mode] = new Map(snapshots[mode].map(it => [it.id, it]));
|
||||||
|
}
|
||||||
|
for (const w of active) {
|
||||||
|
const it = maps[w.mode].get(w.id);
|
||||||
|
if (it) applyMetrics(w, it);
|
||||||
|
}
|
||||||
|
if (searchPick) {
|
||||||
|
const it = maps[searchPick.mode]?.get(searchPick.id);
|
||||||
|
if (it) {
|
||||||
|
const curr = resolveCurrent(it);
|
||||||
|
const low = it.low24hPrice ?? null;
|
||||||
|
const high = it.high24hPrice ?? null;
|
||||||
|
pricesEl.innerHTML =
|
||||||
|
`Current: <strong>${fmtRubles(curr)}</strong><br>` +
|
||||||
|
`24h Low / High: ${fmtRubles(low)} / ${fmtRubles(high)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderWatchTable();
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = 'Error: ' + e.message;
|
||||||
|
} finally {
|
||||||
|
state.isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMetrics(w, it) {
|
||||||
|
const curr = resolveCurrent(it);
|
||||||
|
const low = it.low24hPrice ?? null;
|
||||||
|
const high = it.high24hPrice ?? null;
|
||||||
|
w.last = { curr, low, high };
|
||||||
|
const hit = (w.direction === 'above' && curr >= w.target)
|
||||||
|
|| (w.direction === 'below' && curr <= w.target);
|
||||||
|
if (hit && !w.triggered) {
|
||||||
|
w.triggered = true;
|
||||||
|
playBeep();
|
||||||
|
alert(`TARGET HIT!\n\n${w.name}\nCurrent: ${fmtRubles(curr)}\nTarget: ${fmtRubles(w.target)}\nMode: ${w.mode.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
if (searchPick && searchPick.id === w.id && searchPick.mode === w.mode) {
|
||||||
|
pricesEl.innerHTML =
|
||||||
|
`Current: <strong>${fmtRubles(curr)}</strong><br>` +
|
||||||
|
`24h Low / High: ${fmtRubles(low)} / ${fmtRubles(high)}`;
|
||||||
|
}
|
||||||
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
statusEl.textContent = `[${ts}] Updated ${w.name}: ${fmtRubles(curr)} (24h L/H ${fmtRubles(low)} / ${fmtRubles(high)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/***** Add to watch *****/
|
||||||
|
async function addToWatch() {
|
||||||
|
if (!searchPick) return;
|
||||||
|
const dir = $('#pc-direction').value;
|
||||||
|
const tVal = Number($('#pc-target').value);
|
||||||
|
if (!Number.isFinite(tVal) || tVal < 0) {
|
||||||
|
alert('Enter a valid target (Rubles).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
id: searchPick.id,
|
||||||
|
name: searchPick.name,
|
||||||
|
mode: searchPick.mode,
|
||||||
|
direction: dir,
|
||||||
|
target: tVal,
|
||||||
|
triggered: false,
|
||||||
|
last: {}
|
||||||
|
};
|
||||||
|
const exists = state.watch.some(w => w.id === entry.id && w.mode === entry.mode);
|
||||||
|
if (exists) { alert('Already watching this item in this mode.'); return; }
|
||||||
|
state.watch.push(entry);
|
||||||
|
renderWatchTable();
|
||||||
|
try { await refreshSpecific([entry]); } finally { renderWatchTable(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/***** Events *****/
|
||||||
|
// Buttons
|
||||||
|
$('#pc-btnSearch')?.addEventListener('click', doSearch);
|
||||||
|
addBtn?.addEventListener('click', addToWatch);
|
||||||
|
|
||||||
|
// Search results (pick one)
|
||||||
|
resultsEl?.addEventListener('click', ev => {
|
||||||
|
const li = ev.target.closest('li');
|
||||||
|
if (!li) return;
|
||||||
|
const it = {
|
||||||
|
id: li.dataset.id,
|
||||||
|
name: li.dataset.name,
|
||||||
|
lastLowPrice: li.dataset.lp ? Number(li.dataset.lp) : null,
|
||||||
|
low24hPrice: li.dataset.low ? Number(li.dataset.low) : null,
|
||||||
|
high24hPrice: li.dataset.high ? Number(li.dataset.high) : null
|
||||||
|
};
|
||||||
|
const mode = li.dataset.mode;
|
||||||
|
pickItem(it, mode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch table actions
|
||||||
|
tbodyEl?.addEventListener('click', ev => {
|
||||||
|
const btn = ev.target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const key = btn.dataset.key;
|
||||||
|
const idx = state.watch.findIndex(w => keyOf(w) === key);
|
||||||
|
if (idx === -1) return;
|
||||||
|
if (action === 'remove') {
|
||||||
|
state.watch.splice(idx, 1);
|
||||||
|
renderWatchTable();
|
||||||
|
} else if (action === 'resume') {
|
||||||
|
state.watch[idx].triggered = false;
|
||||||
|
refreshSpecific([state.watch[idx]]).then(() => renderWatchTable());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mode flip => refresh LM badge schedule
|
||||||
|
document.querySelectorAll('input[name="pc-mode"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', async () => {
|
||||||
|
const lm = await readAndRenderLastUpdated();
|
||||||
|
if (lm) state.lastKnownUpdatedMs = lm;
|
||||||
|
scheduleNextCronAlarm();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Enter key behaviors you requested =====
|
||||||
|
// 1) Enter in item input => search, then focus target
|
||||||
|
const pcSearchInput = document.getElementById('pc-search');
|
||||||
|
pcSearchInput?.addEventListener('keydown', async (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await doSearch();
|
||||||
|
document.getElementById('pc-target')?.focus();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) Enter in target input => add (if enabled)
|
||||||
|
const pcTargetInput = document.getElementById('pc-target');
|
||||||
|
pcTargetInput?.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const add = document.getElementById('pc-add');
|
||||||
|
if (!add?.disabled) addToWatch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Kickoff =====
|
||||||
|
(async function init() {
|
||||||
|
await syncServerClock('/items_pvp.json').catch(()=>{});
|
||||||
|
state.lastKnownUpdatedMs = await readAndRenderLastUpdated();
|
||||||
|
scheduleNextCronAlarm();
|
||||||
|
startCountdown();
|
||||||
|
renderWatchTable();
|
||||||
|
})();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
input: document.getElementById('questInput'),
|
||||||
|
button: document.getElementById('questBtn'),
|
||||||
|
status: document.getElementById('questStatus'),
|
||||||
|
results: document.getElementById('questResults'),
|
||||||
|
toolbar: document.querySelector('.quest-toolbar')
|
||||||
|
};
|
||||||
|
|
||||||
|
function showStatus(msg, ok = false) {
|
||||||
|
if (!els.status) return;
|
||||||
|
els.status.textContent = msg;
|
||||||
|
els.status.style.color = ok ? 'var(--fg)' : 'var(--fg-soft)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(s = '') {
|
||||||
|
return s.toString()
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJSON(url) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { cache: 'no-store' });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allTasks = null;
|
||||||
|
|
||||||
|
async function loadLocalData() {
|
||||||
|
if (Array.isArray(allTasks)) return allTasks;
|
||||||
|
|
||||||
|
els.results?.setAttribute('aria-busy', 'true');
|
||||||
|
try {
|
||||||
|
const json = await fetchJSON('/quests.json');
|
||||||
|
const tasks =
|
||||||
|
(Array.isArray(json?.tasks) && json.tasks) ||
|
||||||
|
(Array.isArray(json?.data?.tasks) && json.data.tasks) ||
|
||||||
|
[];
|
||||||
|
// ✅ FIX: assign the cache
|
||||||
|
allTasks = tasks;
|
||||||
|
return allTasks;
|
||||||
|
} finally {
|
||||||
|
els.results?.removeAttribute('aria-busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuest(task) {
|
||||||
|
const wrap = document.createElement('article');
|
||||||
|
wrap.className = 'quest-card trader-card';
|
||||||
|
|
||||||
|
const h2 = document.createElement('h2');
|
||||||
|
h2.className = 'quest-name trader-name';
|
||||||
|
h2.textContent = task.name || 'Unknown Quest';
|
||||||
|
wrap.appendChild(h2);
|
||||||
|
|
||||||
|
if (task.trader || task.wikiLink) {
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'quest-meta';
|
||||||
|
|
||||||
|
if (task.trader) {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'quest-trader';
|
||||||
|
box.style.display = 'flex';
|
||||||
|
box.style.alignItems = 'center';
|
||||||
|
if (task.trader.imageLink) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = task.trader.imageLink;
|
||||||
|
img.alt = task.trader.name || 'Trader';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.width = 36;
|
||||||
|
img.height = 36;
|
||||||
|
img.style.borderRadius = '6px';
|
||||||
|
img.style.marginRight = '8px';
|
||||||
|
img.style.border = '1px solid rgba(0,255,102,0.2)';
|
||||||
|
box.appendChild(img);
|
||||||
|
}
|
||||||
|
const nm = document.createElement('span');
|
||||||
|
nm.textContent = task.trader.name || 'Trader';
|
||||||
|
box.appendChild(nm);
|
||||||
|
meta.appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.wikiLink) {
|
||||||
|
const wiki = document.createElement('a');
|
||||||
|
wiki.href = task.wikiLink;
|
||||||
|
wiki.target = '_blank';
|
||||||
|
wiki.rel = 'noopener noreferrer';
|
||||||
|
wiki.className = 'btn';
|
||||||
|
wiki.style.marginLeft = 'auto';
|
||||||
|
wiki.textContent = 'Open Wiki';
|
||||||
|
meta.appendChild(wiki);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectives = Array.isArray(task.objectives) ? task.objectives : [];
|
||||||
|
const ol = document.createElement('ol');
|
||||||
|
ol.className = 'quest-objectives';
|
||||||
|
|
||||||
|
if (!objectives.length) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'quest-objective';
|
||||||
|
li.textContent = 'No objectives listed.';
|
||||||
|
ol.appendChild(li);
|
||||||
|
} else {
|
||||||
|
for (const obj of objectives) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'quest-objective';
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
const type = obj.type ? `<strong>${escapeHTML(obj.type)}</strong>` : '<strong>Objective</strong>';
|
||||||
|
const desc = escapeHTML(obj.description || '—');
|
||||||
|
const maps = (obj.maps && obj.maps.length)
|
||||||
|
? ` — Map: ${escapeHTML(obj.maps.map(m => m.normalizedName).join(', '))}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
lines.push(`${type}: ${desc}${maps}`);
|
||||||
|
|
||||||
|
if (typeof obj.count === 'number' && (obj.item || (obj.items && obj.items.length))) {
|
||||||
|
const baseName = obj.item?.name || obj.items?.[0]?.name || 'item';
|
||||||
|
const fir = obj.foundInRaid ? ' (FIR)' : '';
|
||||||
|
lines.push(`Hand in: ${escapeHTML(baseName)} × ${obj.count}${escapeHTML(fir)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.targetNames && obj.targetNames.length && typeof obj.count === 'number') {
|
||||||
|
lines.push(`Eliminate: ${escapeHTML(obj.targetNames.join(', '))} × ${obj.count}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.innerHTML = lines.map(l => `<div>${l}</div>`).join('');
|
||||||
|
ol.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.appendChild(ol);
|
||||||
|
|
||||||
|
const fr = task.finishRewards;
|
||||||
|
if (fr) {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'quest-rewards';
|
||||||
|
|
||||||
|
function addRow(label, valueHTML) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.innerHTML = `<strong>${escapeHTML(label)}:</strong> ${valueHTML}`;
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(fr.items) && fr.items.length) {
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.style.display = 'grid';
|
||||||
|
grid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(180px, 1fr))';
|
||||||
|
grid.style.gap = '8px';
|
||||||
|
|
||||||
|
for (const g of fr.items) {
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.style.display = 'flex';
|
||||||
|
c.style.alignItems = 'center';
|
||||||
|
c.style.gap = '8px';
|
||||||
|
|
||||||
|
if (g.item?.iconLink) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = g.item.iconLink;
|
||||||
|
img.alt = g.item.name || '';
|
||||||
|
img.width = 24;
|
||||||
|
img.height = 24;
|
||||||
|
img.style.border = '1px solid rgba(0,255,102,0.2)';
|
||||||
|
img.style.borderRadius = '4px';
|
||||||
|
c.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = document.createElement('span');
|
||||||
|
const nm = g.item?.name || 'Item';
|
||||||
|
const ct = (typeof g.count === 'number') ? ` × ${g.count}` : '';
|
||||||
|
span.textContent = `${nm}${ct}`;
|
||||||
|
c.appendChild(span);
|
||||||
|
|
||||||
|
grid.appendChild(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.innerHTML = `<strong>Items:</strong>`;
|
||||||
|
row.appendChild(grid);
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(fr.traderStanding) && fr.traderStanding.length) {
|
||||||
|
const parts = fr.traderStanding.map(s => {
|
||||||
|
const t = s.trader?.name || 'Trader';
|
||||||
|
const v = typeof s.standing === 'number'
|
||||||
|
? `${s.standing > 0 ? '+' : ''}${s.standing}`
|
||||||
|
: (s.standing ?? '');
|
||||||
|
return `${t}: ${v}`;
|
||||||
|
});
|
||||||
|
addRow('Standing', escapeHTML(parts.join(', ')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fr.offerUnlock) {
|
||||||
|
const list = Array.isArray(fr.offerUnlock) ? fr.offerUnlock : [fr.offerUnlock];
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
for (const u of list) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const itemName = u?.item?.name || 'Item';
|
||||||
|
const traderName = u?.trader?.name || 'Trader';
|
||||||
|
li.textContent = `${itemName} @ ${traderName}`;
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.innerHTML = `<strong>Offer unlocks:</strong>`;
|
||||||
|
row.appendChild(ul);
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fr.skillLevelReward) {
|
||||||
|
const list = Array.isArray(fr.skillLevelReward) ? fr.skillLevelReward : [fr.skillLevelReward];
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
for (const s of list) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const skillObj = s?.skill;
|
||||||
|
const skillName = skillObj?.name || skillObj?.id || 'Skill';
|
||||||
|
const lvl = typeof s?.level === 'number' ? ` +${s.level}` : '';
|
||||||
|
li.textContent = `${skillName}${lvl}`;
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.innerHTML = `<strong>Skill rewards:</strong>`;
|
||||||
|
row.appendChild(ul);
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fr.traderUnlock) {
|
||||||
|
const list = Array.isArray(fr.traderUnlock) ? fr.traderUnlock : [fr.traderUnlock];
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
for (const t of list) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = t?.name || t?.id || 'Trader unlock';
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.innerHTML = `<strong>Trader unlocks:</strong>`;
|
||||||
|
row.appendChild(ul);
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fr.craftUnlock) {
|
||||||
|
const list = Array.isArray(fr.craftUnlock) ? fr.craftUnlock : [fr.craftUnlock];
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
for (const p of list) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = p?.name || p?.id || 'Craft unlock';
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.innerHTML = `<strong>Craft unlocks:</strong>`;
|
||||||
|
row.appendChild(ul);
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fr.achievement) {
|
||||||
|
const list = Array.isArray(fr.achievement) ? fr.achievement : [fr.achievement];
|
||||||
|
const line = list.map(a => a?.name || a?.id || 'Achievement').join(', ');
|
||||||
|
addRow('Achievement', escapeHTML(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fr.customization) {
|
||||||
|
const list = Array.isArray(fr.customization) ? fr.customization : [fr.customization];
|
||||||
|
const line = list.map(c => c?.name || c?.id || 'Customization').join(', ');
|
||||||
|
addRow('Customization', escapeHTML(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (box.children.length) wrap.appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateAltMatchesContainer() {
|
||||||
|
const id = 'questAltMatches';
|
||||||
|
let box = document.getElementById(id);
|
||||||
|
if (box) {
|
||||||
|
box.hidden = false;
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
const results = document.getElementById('questResults');
|
||||||
|
if (!results || !results.parentNode) return null;
|
||||||
|
box = document.createElement('div');
|
||||||
|
box.id = id;
|
||||||
|
box.className = 'quest-alt-matches';
|
||||||
|
results.parentNode.insertBefore(box, results);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMatchList(matches) {
|
||||||
|
const altBox = getOrCreateAltMatchesContainer();
|
||||||
|
if (!altBox) return;
|
||||||
|
altBox.innerHTML = '';
|
||||||
|
if (!matches.length) {
|
||||||
|
altBox.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = document.createElement('details');
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.textContent = `Show ${matches.length} match(es)`;
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
ul.style.listStyle = 'disc';
|
||||||
|
ul.style.paddingLeft = '1.25rem';
|
||||||
|
|
||||||
|
for (const m of matches) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'linklike';
|
||||||
|
btn.textContent = m.trader?.name ? `${m.name} — ${m.trader.name}` : m.name;
|
||||||
|
btn.addEventListener('click', () => openTaskDetails(m.id));
|
||||||
|
li.appendChild(btn);
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
details.appendChild(ul);
|
||||||
|
altBox.appendChild(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTaskDetails(id) {
|
||||||
|
try {
|
||||||
|
showStatus('Loading task…');
|
||||||
|
els.results.innerHTML = '';
|
||||||
|
|
||||||
|
const list = await loadLocalData();
|
||||||
|
const task = list.find(t => t?.id === id);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
showStatus('Task not found', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = renderQuest(task);
|
||||||
|
els.results.appendChild(card);
|
||||||
|
|
||||||
|
showStatus('Ready', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[QuestSearch] open details failed:', err);
|
||||||
|
showStatus('Failed to load task details.', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const q = (els.input?.value || '').trim();
|
||||||
|
const alt = getOrCreateAltMatchesContainer();
|
||||||
|
els.results.innerHTML = '';
|
||||||
|
if (alt) alt.innerHTML = '';
|
||||||
|
if (!q) {
|
||||||
|
if (alt) alt.hidden = true;
|
||||||
|
showStatus('Type a quest name to search.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
showStatus('Searching…');
|
||||||
|
const list = await loadLocalData();
|
||||||
|
const safe = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const re = new RegExp(safe, 'i');
|
||||||
|
const matches = (Array.isArray(list) ? list : []).filter(t => re.test(t.name || ''));
|
||||||
|
renderMatchList(matches);
|
||||||
|
showStatus(`Found ${matches.length} match(es)`, true);
|
||||||
|
if (matches.length === 1) await openTaskDetails(matches[0].id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[QuestSearch] search failed:', err);
|
||||||
|
showStatus('Search failed. Check console.', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastEnterTs = 0;
|
||||||
|
const ENTER_DEBOUNCE_MS = 250;
|
||||||
|
els.button?.addEventListener('click', search);
|
||||||
|
els.input?.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastEnterTs < ENTER_DEBOUNCE_MS) return;
|
||||||
|
lastEnterTs = now;
|
||||||
|
e.preventDefault();
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
showStatus('Waiting to search…');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>ESCAPE FROM TARKOV COMPANION — Quests</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<!-- Correct stylesheet include -->
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<!-- Page-specific overrides (only affect this page) -->
|
||||||
|
<style>
|
||||||
|
/* Make the quest card a full-width panel on this page only */
|
||||||
|
.quests .quest-card.trader-card { max-width: 100%; }
|
||||||
|
|
||||||
|
/* Title spacing (center is already in .page-title) */
|
||||||
|
.quests .page-title { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Objectives: compact, no default list indentation */
|
||||||
|
.quests .quest-objectives {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.quests .quest-objective {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-left: 2px solid rgba(0,255,102,0.18);
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta row (title + wiki button) visual rhythm */
|
||||||
|
.quests .quest-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
}
|
||||||
|
.quests .quest-meta .btn { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Two-column layout on desktop (objectives left, rewards right) */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.quests .quest-card.trader-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.quests .quest-meta { grid-column: 1 / -1; }
|
||||||
|
.quests .quest-objectives { grid-column: 1; }
|
||||||
|
.quests .quest-rewards { grid-column: 2; align-self: start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: ensure the alt-matches block shows as a rounded panel in your theme */
|
||||||
|
.quest-alt-matches[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
/* ====== NEW: Match Price Watch input look (padding/width) ====== */
|
||||||
|
.quests .quest-toolbar input[type="search"] {
|
||||||
|
padding: .45rem .6rem; /* same feel as pricewatch inputs */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Center the search input and button on the Quests page */
|
||||||
|
.quests .quest-toolbar {
|
||||||
|
justify-content: center; /* centers children in the flex row from styles.css */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: keep the status from crowding the button a bit */
|
||||||
|
.quests #questStatus {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- ======= TOP BAR ======= -->
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="logo" href="index.html">ESCAPE FROM TARKOV COMPANION</a>
|
||||||
|
<!-- Hamburger (mobile) -->
|
||||||
|
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation">
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<!-- Primary nav -->
|
||||||
|
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="goons.html">Goons</a></li>
|
||||||
|
<li><a href="maps.html">Maps</a></li>
|
||||||
|
<li><a href="quests.html" aria-current="page">Quests</a></li>
|
||||||
|
<li><a href="ammo.html">Ammo</a></li>
|
||||||
|
<li><a href="traders.html">Traders</a></li>
|
||||||
|
<li><a href="pricewatch.html">Price Watch</a></li>
|
||||||
|
<li><a href="crafts.html">Craft Calculator</a></li>
|
||||||
|
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
|
||||||
|
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener noreferrer">Flea Market</a></li>
|
||||||
|
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki"
|
||||||
|
target="_blank" rel="noopener noreferrer">Wiki</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ======= PAGE CONTENT ======= -->
|
||||||
|
<main class="content">
|
||||||
|
<section class="quests" aria-labelledby="quest-title">
|
||||||
|
<h1 id="quest-title" class="page-title">Quest Search</h1>
|
||||||
|
|
||||||
|
<!-- Search toolbar -->
|
||||||
|
|
||||||
|
<div class="quest-toolbar trader-toolbar">
|
||||||
|
<input
|
||||||
|
id="questInput"
|
||||||
|
type="search"
|
||||||
|
size="28"
|
||||||
|
placeholder="Type a quest name (e.g., Living High is Not a Crime)"
|
||||||
|
aria-label="Quest name">
|
||||||
|
|
||||||
|
<button id="questBtn" class="btn" type="button">Search</button>
|
||||||
|
<span id="questStatus" class="status">Waiting to search…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- “Other matches” list (populated by JS when there are multiple hits) -->
|
||||||
|
<div id="questAltMatches" class="quest-alt-matches" hidden></div>
|
||||||
|
|
||||||
|
<!-- Main results (the quest card is rendered here) -->
|
||||||
|
<div id="questResults"
|
||||||
|
class="quest-results grid-root"
|
||||||
|
role="region"
|
||||||
|
aria-live="polite"></div>
|
||||||
|
|
||||||
|
<p class="small-muted" style="text-align:center; margin-top:1rem;">
|
||||||
|
<!--Quest data is served from a local cache file (<code>/var/www/html/quests.json</code>).-->
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<p class="error">JavaScript is required to search and display quest details.</p>
|
||||||
|
</noscript>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ======= HAMBURGER MENU SCRIPT ======= -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const btn = document.querySelector('.nav-toggle');
|
||||||
|
const nav = document.getElementById('primary-nav');
|
||||||
|
if (btn && nav) {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
btn.setAttribute('aria-expanded', String(!open));
|
||||||
|
nav.classList.toggle('is-open', !open);
|
||||||
|
document.body.classList.toggle('nav-open', !open);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Clean quest search (no guide-images code) -->
|
||||||
|
<script src="quest-search-local.js?v=clean-1.0.3"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/* =======================
|
||||||
|
Console Green Theme
|
||||||
|
======================= */
|
||||||
|
:root {
|
||||||
|
--bg: #0b0f0c;
|
||||||
|
--fg: #00ff66;
|
||||||
|
--fg-soft: #66ff99;
|
||||||
|
--bar: #1a1a1a;
|
||||||
|
--bar-border: #242424;
|
||||||
|
}
|
||||||
|
/* Reset */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { background-color: var(--bg); }
|
||||||
|
html, body { overflow-x: hidden; } /* prevent sideways scroll */
|
||||||
|
body {
|
||||||
|
background: var(--bg); color: var(--fg);
|
||||||
|
font-family: Consolas, "Liberation Mono", Menlo, "DejaVu Sans Mono", "Source Code Pro", "Courier New", monospace;
|
||||||
|
line-height: 1.6; letter-spacing: 0.02em;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 102, 0.3);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
/* =======================
|
||||||
|
Top Bar
|
||||||
|
======================= */
|
||||||
|
.topbar {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; height: 56px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
background: var(--bar); border-bottom: 1px solid var(--bar-border);
|
||||||
|
padding: 0 20px; z-index: 1000;
|
||||||
|
}
|
||||||
|
.topbar, .topbar a { color: var(--fg); text-shadow: none; }
|
||||||
|
.logo { font-weight: 600; letter-spacing: 0.04em; text-decoration: none; }
|
||||||
|
/* Nav */
|
||||||
|
.main-nav ul { list-style: none; display: flex; gap: 20px; }
|
||||||
|
.main-nav a { text-decoration: none; border-bottom: 2px solid transparent; }
|
||||||
|
.main-nav a:hover { color: var(--fg-soft); }
|
||||||
|
/* =======================
|
||||||
|
Content
|
||||||
|
======================= */
|
||||||
|
.content {
|
||||||
|
max-width: 1400px; margin: 0 auto; padding: 96px 20px 40px;
|
||||||
|
}
|
||||||
|
.page-title { text-align: center; margin-bottom: 1rem; }
|
||||||
|
/* =======================
|
||||||
|
Toolbar
|
||||||
|
======================= */
|
||||||
|
.trader-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||||
|
.toolbar-flex { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; min-width: 0; }
|
||||||
|
.toolbar-flex .spacer { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.btn {
|
||||||
|
background: #1f2636; color: var(--fg);
|
||||||
|
border: 1px solid #2a3246; border-radius: 8px;
|
||||||
|
padding: 8px 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #273049; }
|
||||||
|
/* Toggle */
|
||||||
|
.mode-toggle {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px; border: 1px solid #2a3246; border-radius: 8px;
|
||||||
|
background: #1a2030; user-select: none;
|
||||||
|
}
|
||||||
|
.mode-toggle input[type="checkbox"] { width: 18px; height: 18px; accent-color: #00ff66; cursor: pointer; }
|
||||||
|
.mode-toggle span { color: var(--fg); font-size: 0.95rem; }
|
||||||
|
.mode-toggle:hover { background: #20273a; }
|
||||||
|
/* =======================
|
||||||
|
Unified update bubble
|
||||||
|
======================= */
|
||||||
|
.update-bar { width: 100%; margin: 4px 0 8px; }
|
||||||
|
.badge {
|
||||||
|
padding: .25rem .5rem; border-radius: 999px;
|
||||||
|
background: rgba(0,255,102,.08); border: 1px solid rgba(0,255,102,.25);
|
||||||
|
color: #a7f3d0; font-size: .85rem; display: inline-flex; align-items: center;
|
||||||
|
max-width: 100%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.update-pill {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
padding: .35rem .65rem;
|
||||||
|
}
|
||||||
|
/* =======================
|
||||||
|
Trader Grid & Cards
|
||||||
|
======================= */
|
||||||
|
.trader-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 28px; justify-items: center; align-items: start; width: 100%;
|
||||||
|
}
|
||||||
|
.trader-card {
|
||||||
|
width: 100%; max-width: 320px;
|
||||||
|
background: #151924; border-radius: 12px; padding: 14px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex; flex-direction: column; box-sizing: border-box;
|
||||||
|
transition: transform .18s ease, box-shadow .18s ease;
|
||||||
|
}
|
||||||
|
/* Hover animation on devices that support hover */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.trader-card:hover { transform: translateY(-6px); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45); }
|
||||||
|
}
|
||||||
|
.trader-figure {
|
||||||
|
width: 100%; aspect-ratio: 4 / 3; margin-bottom: 10px;
|
||||||
|
overflow: hidden; border-radius: 8px; border: 1px solid rgba(0, 255, 102, 0.18);
|
||||||
|
}
|
||||||
|
.trader-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.trader-name { font-weight: 700; margin-bottom: 6px; color: var(--fg); }
|
||||||
|
.trader-countdown { font-size: 1.25rem; font-variant-numeric: tabular-nums; color: var(--fg); }
|
||||||
|
.trader-meta { font-size: 0.9rem; margin-top: 6px; color: var(--fg-soft); white-space: nowrap; }
|
||||||
|
/* Single column on small phones */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.trader-grid { grid-template-columns: 1fr; gap: 20px; }
|
||||||
|
.trader-card { max-width: 100%; }
|
||||||
|
}
|
||||||
|
/* =======================
|
||||||
|
CRT overlay
|
||||||
|
======================= */
|
||||||
|
html::before {
|
||||||
|
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 9999;
|
||||||
|
background-image: repeating-linear-gradient(to bottom,
|
||||||
|
rgba(0, 255, 102, 0.035), rgba(0, 255, 102, 0.035) 1px,
|
||||||
|
rgba(0, 0, 0, 0) 3px, rgba(0, 0, 0, 0) 8px);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
/* =======================
|
||||||
|
Hamburger menu (mobile)
|
||||||
|
======================= */
|
||||||
|
.nav-toggle {
|
||||||
|
display: inline-grid; grid-auto-rows: 3px; gap: 4px;
|
||||||
|
align-items: center; justify-content: center; width: 40px; height: 40px;
|
||||||
|
padding: 0; background: transparent; border: 1px solid transparent; border-radius: 8px;
|
||||||
|
color: var(--fg); cursor: pointer; transform: translateY(10px);
|
||||||
|
}
|
||||||
|
.nav-toggle:focus-visible { outline: 2px solid rgba(0, 255, 102, .7); outline-offset: 2px; }
|
||||||
|
.nav-toggle__bar { width: 22px; height: 2px; background: var(--fg); border-radius: 2px; box-shadow: 0 0 6px rgba(0, 255, 102, .45); }
|
||||||
|
/* Mobile dropdown nav */
|
||||||
|
.main-nav {
|
||||||
|
position: absolute; top: 56px; left: 0; right: 0; display: none;
|
||||||
|
background: var(--bar); border-bottom: 1px solid var(--bar-border); padding: 12px 16px 16px;
|
||||||
|
}
|
||||||
|
.main-nav.is-open { display: block; animation: menuDrop 160ms ease-out; }
|
||||||
|
.main-nav.is-open ul { display: grid; gap: 10px; }
|
||||||
|
.main-nav a { display: block; padding: 10px 12px; color: var(--fg); }
|
||||||
|
@keyframes menuDrop { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
/* Desktop nav */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.nav-toggle { display: none; }
|
||||||
|
.main-nav { position: static; display: block; background: transparent; border: 0; padding: 0; }
|
||||||
|
.main-nav ul { display: flex; gap: 20px; }
|
||||||
|
.main-nav a { display: inline-block; padding: 6px 8px; }
|
||||||
|
}
|
||||||
|
body.nav-open { overflow: hidden; }
|
||||||
|
/* Optional: styling for the matches block injected above results */
|
||||||
|
.quest-alt-matches {
|
||||||
|
background: #151924;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(0, 255, 102, 0.18);
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
}
|
||||||
|
.quest-alt-matches details > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.quest-alt-matches .linklike {
|
||||||
|
background: none;
|
||||||
|
color: var(--fg);
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
/* General hero title used across pages */
|
||||||
|
.hero {
|
||||||
|
font-size: 44px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sortable header styles */
|
||||||
|
#ammoTable thead th.is-sortable { cursor: pointer; user-select: none; }
|
||||||
|
#ammoTable thead th.is-sorted-asc::after { content: " ▲"; color: #8fd8b3; }
|
||||||
|
#ammoTable thead th.is-sorted-desc::after { content: " ▼"; color: #8fd8b3; }
|
||||||
|
|
||||||
|
.table-scroll { overflow:auto; border:1px solid rgba(0,255,102,.25); }
|
||||||
|
.spread-table { width:100%; border-collapse:collapse; }
|
||||||
|
.spread-table th, .spread-table td { padding:.5rem .75rem; border-bottom:1px solid rgba(0,255,102,.15); }
|
||||||
|
.spread-table thead th { position:sticky; top:0; background:#0b0b0b; }
|
||||||
|
.spread-table tbody tr:hover { background:rgba(0,255,102,.05); }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUT="/var/www/EFT_COMPANION/public_html/quests.json"
|
||||||
|
ENDPOINT="https://api.tarkov.dev/graphql"
|
||||||
|
|
||||||
|
# Use a single-line GraphQL query to avoid escaping headaches
|
||||||
|
# (Also removed fields that caused schema errors: Skill.normalizedName, craftUnlock.name)
|
||||||
|
QUERY='query AllTasks { tasks { id name trader { id name imageLink } wikiLink objectives { id type description maps { normalizedName } ... on TaskObjectiveItem { count foundInRaid item { id name shortName iconLink } items { id name shortName iconLink } } ... on TaskObjectiveShoot { targetNames count } } finishRewards { items { count item { id name shortName iconLink } } traderStanding { standing trader { id name } } offerUnlock { item { id name } trader { id name } } skillLevelReward { level skill { id name } } traderUnlock { id name } craftUnlock { id } achievement { id name } customization { id name } } } }'
|
||||||
|
|
||||||
|
# Build minimal JSON payload without external tools (query has no double quotes)
|
||||||
|
PAYLOAD=$(printf '{"query":"%s"}' "$QUERY")
|
||||||
|
|
||||||
|
# Write to a temp file in the same directory, then atomically mv into place
|
||||||
|
TMP="$(mktemp -p /var/www/EFT_COMPANION/public_html/quests.json.tmp.XXXXXX)"
|
||||||
|
|
||||||
|
echo "[update-quests] Posting to $ENDPOINT ..."
|
||||||
|
HTTP_STATUS="$(curl -sS -o "$TMP" -w '%{http_code}' \
|
||||||
|
-X POST "$ENDPOINT" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary "$PAYLOAD")"
|
||||||
|
|
||||||
|
if [ "$HTTP_STATUS" != "200" ]; then
|
||||||
|
echo "[update-quests] ERROR: HTTP $HTTP_STATUS"
|
||||||
|
echo "[update-quests] Response body (first 2000 bytes):"
|
||||||
|
head -c 2000 "$TMP"; echo
|
||||||
|
rm -f "$TMP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod 0644 "$TMP"
|
||||||
|
mv -f "$TMP" "$OUT"
|
||||||
|
echo "[update-quests] Wrote $OUT at $(date -Is)"
|
||||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,629 @@
|
|||||||
|
/* top_spreads.js
|
||||||
|
* Price Watch with Settings Drawer + PvE/PvP game mode switch
|
||||||
|
* Trader → Flea view includes Trader name + required Loyalty Level (LL)
|
||||||
|
* Adds “Under Avg ₽” column based on current (lastLow → low24h) vs avg24h
|
||||||
|
* Flea Market is NEVER treated as a trader. Unknown sources are skipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Configuration & defaults
|
||||||
|
========================= */
|
||||||
|
const ENDPOINT = 'https://api.tarkov.dev/graphql'; // Tarkov.dev GraphQL
|
||||||
|
const CACHE_KEY = 'tarkov_items_cache_v1';
|
||||||
|
const CACHE_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// Recognized traders (strict allow-list)
|
||||||
|
const TRADERS = new Set([
|
||||||
|
'Prapor', 'Therapist', 'Fence', 'Skier',
|
||||||
|
'Peacekeeper', 'Mechanic', 'Ragman', 'Jaeger'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Persisted settings
|
||||||
|
const SETTINGS_KEY = 'tarkov_spread_settings_v1';
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
// Denoising / realism gates
|
||||||
|
minOffers: 30, // gate thin markets (0 disables; try 30–80)
|
||||||
|
maxSpreadRatio: 2.0, // reject if high24h / low24h > X
|
||||||
|
maxAvgToLow: 1.25, // reject if avg24h / lastLow > X
|
||||||
|
// Flea fee approximation (rough % cut to avoid overstating profit)
|
||||||
|
applyFleaFee: true,
|
||||||
|
feePercent: 9, // displayed as percent; converted to 0.09
|
||||||
|
// Game mode: 'regular' (PvP economy) or 'pve'
|
||||||
|
gameMode: 'regular'
|
||||||
|
};
|
||||||
|
|
||||||
|
// UI state (not persisted)
|
||||||
|
let itemsCache = null; // raw items from GraphQL
|
||||||
|
let currentMode = 'spread'; // 'spread' | 'trader'
|
||||||
|
let currentRows = [];
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
DOM helpers / formatting
|
||||||
|
========================= */
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
function byId(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function asInt(n) { return Number.isFinite(n) ? Math.trunc(n) : null; }
|
||||||
|
function fmtRUB(n) { return Number.isFinite(n) ? n.toLocaleString('en-US') : '–'; }
|
||||||
|
function nowFmt(d = new Date()) {
|
||||||
|
return d.toLocaleString([], { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
function safeDiv(a, b) { return (Number.isFinite(a) && Number.isFinite(b) && b !== 0) ? (a / b) : null; }
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Settings load/save
|
||||||
|
========================= */
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||||
|
const saved = JSON.parse(raw);
|
||||||
|
return { ...DEFAULT_SETTINGS, ...saved };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveSettings(settings) {
|
||||||
|
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch {}
|
||||||
|
}
|
||||||
|
let SETTINGS = loadSettings();
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
GraphQL query & fetch
|
||||||
|
========================= */
|
||||||
|
const QUERY = `
|
||||||
|
query ItemsWithPrices($mode: GameMode) {
|
||||||
|
items(lang: en, gameMode: $mode) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
shortName
|
||||||
|
basePrice
|
||||||
|
lastLowPrice
|
||||||
|
low24hPrice
|
||||||
|
high24hPrice
|
||||||
|
avg24hPrice
|
||||||
|
lastOfferCount
|
||||||
|
buyFor {
|
||||||
|
source
|
||||||
|
currency
|
||||||
|
price
|
||||||
|
priceRUB
|
||||||
|
vendor { name }
|
||||||
|
requirements { type value } # parsed for loyalty level
|
||||||
|
}
|
||||||
|
sellFor {
|
||||||
|
source
|
||||||
|
currency
|
||||||
|
price
|
||||||
|
priceRUB
|
||||||
|
vendor { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function fetchItems(gameMode = 'regular') {
|
||||||
|
const res = await fetch(ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: QUERY, variables: { mode: gameMode } })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
const items = json?.data?.items ?? [];
|
||||||
|
writeCache(gameMode, items);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Cache (per game mode)
|
||||||
|
========================= */
|
||||||
|
function readCache(gameMode) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const pack = parsed[gameMode];
|
||||||
|
if (!pack) return null;
|
||||||
|
if (Date.now() - (pack.savedAt || 0) > CACHE_MS) return null;
|
||||||
|
return pack.items;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
function writeCache(gameMode, items) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CACHE_KEY);
|
||||||
|
const parsed = raw ? JSON.parse(raw) : {};
|
||||||
|
parsed[gameMode] = { savedAt: Date.now(), items };
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify(parsed));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Price helpers
|
||||||
|
========================= */
|
||||||
|
function priceRUB(p) {
|
||||||
|
if (!p) return null;
|
||||||
|
if (Number.isFinite(p.priceRUB)) return p.priceRUB;
|
||||||
|
if (p.currency === 'RUB' && Number.isFinite(p.price)) return p.price;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedTraderName(offer) {
|
||||||
|
const n = offer?.vendor?.name || offer?.source || null;
|
||||||
|
if (!n || n === 'Flea Market') return null;
|
||||||
|
return TRADERS.has(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only real traders: exclude Flea and unknown sources; require a usable RUB price
|
||||||
|
function bestTraderBuyOffer(item) {
|
||||||
|
const offers = (item.buyFor || []).filter(o => {
|
||||||
|
if (!o || o.source === 'Flea Market') return false;
|
||||||
|
const name = normalizedTraderName(o);
|
||||||
|
const rub = priceRUB(o);
|
||||||
|
return name && Number.isFinite(rub);
|
||||||
|
});
|
||||||
|
let best = null;
|
||||||
|
for (const o of offers) {
|
||||||
|
const rub = priceRUB(o);
|
||||||
|
if (!best || rub < priceRUB(best)) best = o;
|
||||||
|
}
|
||||||
|
return best; // may be null if no acceptable trader offer exists
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestTraderSellRUB(item) {
|
||||||
|
const arr = (item.sellFor || [])
|
||||||
|
.filter(o => o.source !== 'Flea Market' && normalizedTraderName(o))
|
||||||
|
.map(priceRUB)
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
return arr.length ? Math.max(...arr) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function conservativeFleaSell(item) {
|
||||||
|
// Very conservative: lastLowPrice -> low24hPrice -> avg24hPrice
|
||||||
|
return item.lastLowPrice ?? item.low24hPrice ?? item.avg24hPrice ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract name + loyalty (LL). If unknown or flea, return nulls so the row can be skipped.
|
||||||
|
function extractTraderInfo(offer) {
|
||||||
|
const name = normalizedTraderName(offer);
|
||||||
|
if (!name) return { name: null, loyalty: null };
|
||||||
|
|
||||||
|
const req = (offer.requirements || []).find(
|
||||||
|
r => String(r.type || '').toLowerCase().includes('loyalty')
|
||||||
|
);
|
||||||
|
const loyaltyNum = req ? Number.parseInt(req.value, 10) : null;
|
||||||
|
|
||||||
|
return { name, loyalty: Number.isFinite(loyaltyNum) ? loyaltyNum : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateFleaFee(listPriceRUB) {
|
||||||
|
if (!SETTINGS.applyFleaFee || !Number.isFinite(listPriceRUB)) return 0;
|
||||||
|
const factor = Math.max(0, (SETTINGS.feePercent || 0)) / 100;
|
||||||
|
return Math.floor(listPriceRUB * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- New: Under-Avg helper
|
||||||
|
function underAvgDiff(item) {
|
||||||
|
const avg = asInt(item.avg24hPrice ?? null);
|
||||||
|
const current = asInt(item.lastLowPrice ?? item.low24hPrice ?? null);
|
||||||
|
if (!Number.isFinite(avg) || !Number.isFinite(current)) return null;
|
||||||
|
if (current >= avg) return null;
|
||||||
|
return avg - current; // positive number meaning “current is X under average”
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Computations
|
||||||
|
========================= */
|
||||||
|
function computeSpreadRows(items) {
|
||||||
|
const rows = [];
|
||||||
|
const { minOffers, maxSpreadRatio, maxAvgToLow } = SETTINGS;
|
||||||
|
|
||||||
|
for (const it of items) {
|
||||||
|
const offers = it.lastOfferCount ?? 0;
|
||||||
|
if (offers < minOffers) continue;
|
||||||
|
|
||||||
|
const low = asInt(it.low24hPrice ?? it.lastLowPrice ?? null);
|
||||||
|
const high = asInt(it.high24hPrice ?? it.avg24hPrice ?? null);
|
||||||
|
if (!Number.isFinite(low) || !Number.isFinite(high)) continue;
|
||||||
|
|
||||||
|
// Outlier guards
|
||||||
|
const spreadRatio = safeDiv(high, low);
|
||||||
|
if (spreadRatio !== null && spreadRatio > maxSpreadRatio) continue;
|
||||||
|
|
||||||
|
const consLow = it.lastLowPrice ?? it.low24hPrice ?? null;
|
||||||
|
const avgToLow = safeDiv(it.avg24hPrice ?? null, consLow ?? null);
|
||||||
|
if (avgToLow !== null && avgToLow > maxAvgToLow) continue;
|
||||||
|
|
||||||
|
const gap = high - low;
|
||||||
|
if (gap <= 0) continue;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
shortName: it.shortName,
|
||||||
|
gap,
|
||||||
|
low24h: low,
|
||||||
|
high24h: high,
|
||||||
|
avg24h: asInt(it.avg24hPrice ?? null),
|
||||||
|
underAvg: underAvgDiff(it), // NEW
|
||||||
|
traderBuy: asInt(priceRUB(bestTraderBuyOffer(it))),
|
||||||
|
traderSell: asInt(bestTraderSellRUB(it)),
|
||||||
|
offers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => b.gap - a.gap);
|
||||||
|
return rows.slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTraderFlipRows(items) {
|
||||||
|
const rows = [];
|
||||||
|
const { minOffers, maxAvgToLow } = SETTINGS;
|
||||||
|
|
||||||
|
for (const it of items) {
|
||||||
|
const offers = it.lastOfferCount ?? 0;
|
||||||
|
if (offers < minOffers) continue;
|
||||||
|
|
||||||
|
const bestOffer = bestTraderBuyOffer(it); // Strict trader-only offer
|
||||||
|
const traderBuy = asInt(priceRUB(bestOffer)); // Buy from trader (RUB)
|
||||||
|
const fleaSellRaw = asInt(conservativeFleaSell(it));// Conservative flea sell target
|
||||||
|
if (!Number.isFinite(traderBuy) || !Number.isFinite(fleaSellRaw)) continue;
|
||||||
|
|
||||||
|
// Derive trader name + LL, and skip if we can't identify a known trader
|
||||||
|
const tInfo = extractTraderInfo(bestOffer);
|
||||||
|
if (!tInfo.name) continue;
|
||||||
|
|
||||||
|
// Apply fee estimate to flea sell side
|
||||||
|
const fee = estimateFleaFee(fleaSellRaw);
|
||||||
|
const netFleaSell = fleaSellRaw - fee;
|
||||||
|
|
||||||
|
// Guard skew (inflated averages)
|
||||||
|
const consLow = it.lastLowPrice ?? it.low24hPrice ?? null;
|
||||||
|
const avgToLow = safeDiv(it.avg24hPrice ?? null, consLow ?? null);
|
||||||
|
if (avgToLow !== null && avgToLow > maxAvgToLow) continue;
|
||||||
|
|
||||||
|
const profit = netFleaSell - traderBuy;
|
||||||
|
if (profit <= 0) continue;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
shortName: it.shortName,
|
||||||
|
profit,
|
||||||
|
traderBuy,
|
||||||
|
traderName: tInfo.name,
|
||||||
|
traderLL: tInfo.loyalty,
|
||||||
|
fleaSell: netFleaSell, // net after fee
|
||||||
|
avg24h: asInt(it.avg24hPrice ?? null),
|
||||||
|
underAvg: underAvgDiff(it), // NEW
|
||||||
|
offers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => b.profit - a.profit);
|
||||||
|
return rows.slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Rendering (table)
|
||||||
|
========================= */
|
||||||
|
function renderHead(mode) {
|
||||||
|
const thead = byId('spread-thead');
|
||||||
|
if (mode === 'spread') {
|
||||||
|
thead.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<th scope="col">#</th>
|
||||||
|
<th scope="col">Item</th>
|
||||||
|
<th scope="col" id="sort-col" data-key="gap" style="cursor:pointer">Gap ₽</th>
|
||||||
|
<th scope="col">Low24h ₽</th>
|
||||||
|
<th scope="col">High24h ₽</th>
|
||||||
|
<th scope="col">Avg24h ₽</th>
|
||||||
|
<th scope="col">Under Avg ₽</th>
|
||||||
|
<th scope="col">Best Trader Buy ₽</th>
|
||||||
|
<th scope="col">Best Trader Sell ₽</th>
|
||||||
|
<th scope="col">Offers</th>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
thead.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<th scope="col">#</th>
|
||||||
|
<th scope="col">Item</th>
|
||||||
|
<th scope="col" id="sort-col" data-key="profit" style="cursor:pointer">Profit ₽</th>
|
||||||
|
<th scope="col">Trader Buy ₽</th>
|
||||||
|
<th scope="col">Trader (LL)</th>
|
||||||
|
<th scope="col">Flea Sell (after fee) ₽</th>
|
||||||
|
<th scope="col">Avg24h ₽</th>
|
||||||
|
<th scope="col">Under Avg ₽</th>
|
||||||
|
<th scope="col">Offers</th>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(mode, rows) {
|
||||||
|
const tbody = byId('spread-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
let inner = '';
|
||||||
|
if (mode === 'spread') {
|
||||||
|
inner = `
|
||||||
|
<td>${i + 1}</td>
|
||||||
|
<td>${r.name}</td>
|
||||||
|
<td data-value="${r.gap}">${fmtRUB(r.gap)}</td>
|
||||||
|
<td>${fmtRUB(r.low24h)}</td>
|
||||||
|
<td>${fmtRUB(r.high24h)}</td>
|
||||||
|
<td>${fmtRUB(r.avg24h)}</td>
|
||||||
|
<td>${fmtRUB(r.underAvg)}</td>
|
||||||
|
<td>${fmtRUB(r.traderBuy)}</td>
|
||||||
|
<td>${fmtRUB(r.traderSell)}</td>
|
||||||
|
<td>${r.offers ?? '–'}</td>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const traderLLText = `${r.traderName}${Number.isFinite(r.traderLL) ? ` (LL${r.traderLL})` : ' (LL–)'}`;
|
||||||
|
inner = `
|
||||||
|
<td>${i + 1}</td>
|
||||||
|
<td>${r.name}</td>
|
||||||
|
<td data-value="${r.profit}">${fmtRUB(r.profit)}</td>
|
||||||
|
<td>${fmtRUB(r.traderBuy)}</td>
|
||||||
|
<td>${traderLLText}</td>
|
||||||
|
<td>${fmtRUB(r.fleaSell)}</td>
|
||||||
|
<td>${fmtRUB(r.avg24h)}</td>
|
||||||
|
<td>${fmtRUB(r.underAvg)}</td>
|
||||||
|
<td>${r.offers ?? '–'}</td>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = inner.trim();
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireSort(mode, rows) {
|
||||||
|
const sortCol = byId('spread-thead').querySelector('#sort-col');
|
||||||
|
if (!sortCol) return;
|
||||||
|
let desc = true;
|
||||||
|
sortCol.addEventListener('click', () => {
|
||||||
|
desc = !desc;
|
||||||
|
const key = sortCol.dataset.key;
|
||||||
|
rows.sort((a, b) => desc ? (b[key] - a[key]) : (a[key] - b[key]));
|
||||||
|
renderBody(mode, rows);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Settings Drawer (injected)
|
||||||
|
========================= */
|
||||||
|
function injectSettingsUI() {
|
||||||
|
// Add "Settings" button to the existing .controls row (created in your HTML)
|
||||||
|
const controls = document.querySelector('.controls');
|
||||||
|
if (controls && !byId('btn-settings')) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'btn-settings';
|
||||||
|
btn.className = 'mode-btn';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = 'Settings ⚙️';
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const drawer = byId('settings-drawer');
|
||||||
|
const open = drawer?.classList.contains('open');
|
||||||
|
if (open) closeSettings(); else openSettings();
|
||||||
|
});
|
||||||
|
controls.appendChild(btn);
|
||||||
|
|
||||||
|
// Add a small badge to show active game mode
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.id = 'mode-badge';
|
||||||
|
badge.style.marginLeft = '0.5rem';
|
||||||
|
badge.style.opacity = '0.8';
|
||||||
|
badge.textContent = `Mode: ${SETTINGS.gameMode.toUpperCase()}`;
|
||||||
|
controls.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject drawer container once
|
||||||
|
if (!byId('settings-drawer')) {
|
||||||
|
const drawer = document.createElement('div');
|
||||||
|
drawer.id = 'settings-drawer';
|
||||||
|
drawer.style.position = 'fixed';
|
||||||
|
drawer.style.right = '1rem';
|
||||||
|
drawer.style.top = '5rem';
|
||||||
|
drawer.style.width = '340px';
|
||||||
|
drawer.style.maxWidth = '95vw';
|
||||||
|
drawer.style.border = '1px solid rgba(0,255,102,.35)';
|
||||||
|
drawer.style.background = '#0b0b0b';
|
||||||
|
drawer.style.boxShadow = '0 8px 24px rgba(0,0,0,.6), 0 0 14px rgba(0,255,102,.25)';
|
||||||
|
drawer.style.borderRadius = '8px';
|
||||||
|
drawer.style.padding = '12px 14px';
|
||||||
|
drawer.style.zIndex = '9999';
|
||||||
|
drawer.style.display = 'none';
|
||||||
|
|
||||||
|
drawer.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;">
|
||||||
|
<strong style="font-size:1.05rem;">Price Watch Settings</strong>
|
||||||
|
<span style="margin-left:auto;opacity:.7;">PvE/PvP + filters</span>
|
||||||
|
<button id="btn-close-settings" class="mode-btn" style="padding:.25rem .5rem;">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.5rem;">
|
||||||
|
<label>
|
||||||
|
<span>Game Mode</span><br/>
|
||||||
|
<label style="margin-right:1rem;">
|
||||||
|
<input type="radio" name="gm" value="regular"> PvP (Regular)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="gm" value="pve"> PvE
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Min Offers (liquidity gate)</span><br/>
|
||||||
|
<input id="inp-min-offers" type="number" min="0" step="1" style="width:6rem;">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Max Spread Ratio (high/low cap)</span><br/>
|
||||||
|
<input id="inp-max-spread" type="number" min="0" step="0.05" style="width:6rem;">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Max Avg/Low Ratio (skew guard)</span><br/>
|
||||||
|
<input id="inp-max-skew" type="number" min="0" step="0.01" style="width:6rem;">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input id="chk-fee" type="checkbox"> Apply Flea Fee
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Flea Fee % (if applied)</span><br/>
|
||||||
|
<input id="inp-fee-pct" type="number" min="0" step="0.5" style="width:6rem;"> %
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.75rem;">
|
||||||
|
<button id="btn-reset-settings" class="mode-btn">Reset</button>
|
||||||
|
<button id="btn-apply-settings" class="mode-btn is-active">Apply & Refresh</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(drawer);
|
||||||
|
|
||||||
|
// Wire close button
|
||||||
|
byId('btn-close-settings').addEventListener('click', () => closeSettings());
|
||||||
|
|
||||||
|
// Initialize control values from SETTINGS
|
||||||
|
const gmRadios = drawer.querySelectorAll('input[name="gm"]');
|
||||||
|
gmRadios.forEach(r => r.checked = (r.value === SETTINGS.gameMode));
|
||||||
|
byId('inp-min-offers').value = SETTINGS.minOffers;
|
||||||
|
byId('inp-max-spread').value = SETTINGS.maxSpreadRatio;
|
||||||
|
byId('inp-max-skew').value = SETTINGS.maxAvgToLow;
|
||||||
|
byId('chk-fee').checked = !!SETTINGS.applyFleaFee;
|
||||||
|
byId('inp-fee-pct').value = SETTINGS.feePercent;
|
||||||
|
|
||||||
|
// Wire Apply
|
||||||
|
byId('btn-apply-settings').addEventListener('click', async () => {
|
||||||
|
const newSettings = {
|
||||||
|
...SETTINGS,
|
||||||
|
gameMode: [...gmRadios].find(r => r.checked)?.value || SETTINGS.gameMode,
|
||||||
|
minOffers: Math.max(0, parseInt(byId('inp-min-offers').value || DEFAULT_SETTINGS.minOffers, 10)),
|
||||||
|
maxSpreadRatio: Math.max(0, parseFloat(byId('inp-max-spread').value || DEFAULT_SETTINGS.maxSpreadRatio)),
|
||||||
|
maxAvgToLow: Math.max(0, parseFloat(byId('inp-max-skew').value || DEFAULT_SETTINGS.maxAvgToLow)),
|
||||||
|
applyFleaFee: byId('chk-fee').checked,
|
||||||
|
feePercent: Math.max(0, parseFloat(byId('inp-fee-pct').value || DEFAULT_SETTINGS.feePercent))
|
||||||
|
};
|
||||||
|
SETTINGS = newSettings;
|
||||||
|
saveSettings(SETTINGS);
|
||||||
|
updateModeBadge();
|
||||||
|
closeSettings();
|
||||||
|
await refreshData(); // refetch if game mode changed; otherwise re-render
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire Reset
|
||||||
|
byId('btn-reset-settings').addEventListener('click', () => {
|
||||||
|
SETTINGS = { ...DEFAULT_SETTINGS };
|
||||||
|
saveSettings(SETTINGS);
|
||||||
|
// Reset UI fields
|
||||||
|
gmRadios.forEach(r => r.checked = (r.value === SETTINGS.gameMode));
|
||||||
|
byId('inp-min-offers').value = SETTINGS.minOffers;
|
||||||
|
byId('inp-max-spread').value = SETTINGS.maxSpreadRatio;
|
||||||
|
byId('inp-max-skew').value = SETTINGS.maxAvgToLow;
|
||||||
|
byId('chk-fee').checked = !!SETTINGS.applyFleaFee;
|
||||||
|
byId('inp-fee-pct').value = SETTINGS.feePercent;
|
||||||
|
updateModeBadge();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
const drawer = byId('settings-drawer');
|
||||||
|
const btn = byId('btn-settings');
|
||||||
|
if (!drawer) return;
|
||||||
|
drawer.style.display = 'block';
|
||||||
|
drawer.classList.add('open');
|
||||||
|
btn?.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
function closeSettings() {
|
||||||
|
const drawer = byId('settings-drawer');
|
||||||
|
const btn = byId('btn-settings');
|
||||||
|
if (!drawer) return;
|
||||||
|
drawer.style.display = 'none';
|
||||||
|
drawer.classList.remove('open');
|
||||||
|
btn?.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
function updateModeBadge() {
|
||||||
|
const badge = byId('mode-badge');
|
||||||
|
if (badge) badge.textContent = `Mode: ${SETTINGS.gameMode.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Controller: render & refresh
|
||||||
|
========================= */
|
||||||
|
function setMode(viewMode) {
|
||||||
|
if (viewMode !== 'spread' && viewMode !== 'trader') return;
|
||||||
|
currentMode = viewMode;
|
||||||
|
|
||||||
|
// Toggle buttons in your existing controls
|
||||||
|
$('#toggle-spread')?.classList.toggle('is-active', viewMode === 'spread');
|
||||||
|
$('#toggle-trader')?.classList.toggle('is-active', viewMode === 'trader');
|
||||||
|
$('#toggle-spread')?.setAttribute('aria-pressed', String(viewMode === 'spread'));
|
||||||
|
$('#toggle-trader')?.setAttribute('aria-pressed', String(viewMode === 'trader'));
|
||||||
|
|
||||||
|
byId('spread-caption').textContent = viewMode === 'spread'
|
||||||
|
? 'Top 100 flea spreads (High − Low). Data: tarkov.dev'
|
||||||
|
: 'Top 100 Trader → Flea flips (net flea after fee − best trader buy). Data: tarkov.dev';
|
||||||
|
|
||||||
|
if (!itemsCache) return;
|
||||||
|
const rows = (viewMode === 'spread') ? computeSpreadRows(itemsCache) : computeTraderFlipRows(itemsCache);
|
||||||
|
currentRows = rows;
|
||||||
|
|
||||||
|
renderHead(viewMode);
|
||||||
|
renderBody(viewMode, rows);
|
||||||
|
wireSort(viewMode, rows);
|
||||||
|
byId('spread-table').hidden = rows.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshData() {
|
||||||
|
const status = byId('status');
|
||||||
|
const table = byId('spread-table');
|
||||||
|
const lastUpdated = byId('last-updated');
|
||||||
|
|
||||||
|
// Try cache first for this game mode
|
||||||
|
const cached = readCache(SETTINGS.gameMode);
|
||||||
|
if (cached && cached.length) {
|
||||||
|
itemsCache = cached;
|
||||||
|
setMode(currentMode);
|
||||||
|
table.hidden = false;
|
||||||
|
status.textContent = 'Loaded from cache. Refreshing…';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always attempt live refresh for the selected mode
|
||||||
|
try {
|
||||||
|
const items = await fetchItems(SETTINGS.gameMode);
|
||||||
|
itemsCache = items;
|
||||||
|
setMode(currentMode);
|
||||||
|
status.textContent = `Showing top ${currentRows.length} items. Updated just now.`;
|
||||||
|
lastUpdated.textContent = `Updated: ${nowFmt(new Date())}`;
|
||||||
|
table.hidden = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
if (!itemsCache) {
|
||||||
|
status.textContent = 'Failed to load price data. Please try again.';
|
||||||
|
table.hidden = true;
|
||||||
|
} else {
|
||||||
|
status.textContent = 'Using cached data (latest refresh failed).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Boot
|
||||||
|
========================= */
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Wire existing view toggles from your HTML
|
||||||
|
$('#toggle-spread')?.addEventListener('click', () => setMode('spread'));
|
||||||
|
$('#toggle-trader')?.addEventListener('click', () => setMode('trader'));
|
||||||
|
|
||||||
|
// Inject settings UI and show current mode badge
|
||||||
|
injectSettingsUI();
|
||||||
|
updateModeBadge();
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
refreshData();
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# top_spreads.py
|
||||||
|
import requests
|
||||||
|
import csv
|
||||||
|
|
||||||
|
ENDPOINT = "https://api.tarkov.dev/graphql"
|
||||||
|
QUERY = """
|
||||||
|
query ItemsWithPrices {
|
||||||
|
items(lang: en) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
shortName
|
||||||
|
basePrice
|
||||||
|
lastLowPrice
|
||||||
|
low24hPrice
|
||||||
|
high24hPrice
|
||||||
|
avg24hPrice
|
||||||
|
lastOfferCount
|
||||||
|
buyFor { source currency price priceRUB vendor { name } }
|
||||||
|
sellFor { source currency price priceRUB vendor { name } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def pick_rub(p):
|
||||||
|
pr = p.get("priceRUB")
|
||||||
|
if pr is not None:
|
||||||
|
return pr
|
||||||
|
if p.get("currency") == "RUB":
|
||||||
|
return p.get("price")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def best_trader_buy_rub(item):
|
||||||
|
offers = [pick_rub(o) for o in (item.get("buyFor") or []) if o.get("source") != "Flea Market"]
|
||||||
|
offers = [x for x in offers if isinstance(x, (int, float))]
|
||||||
|
return min(offers) if offers else None
|
||||||
|
|
||||||
|
def best_trader_sell_rub(item):
|
||||||
|
offers = [pick_rub(o) for o in (item.get("sellFor") or []) if o.get("source") != "Flea Market"]
|
||||||
|
offers = [x for x in offers if isinstance(x, (int, float))]
|
||||||
|
return max(offers) if offers else None
|
||||||
|
|
||||||
|
resp = requests.post(ENDPOINT, json={"query": QUERY}, timeout=60)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()["data"]["items"]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for it in data:
|
||||||
|
flea_low = it.get("low24hPrice") or it.get("lastLowPrice")
|
||||||
|
flea_high = it.get("high24hPrice") or it.get("avg24hPrice")
|
||||||
|
|
||||||
|
if isinstance(flea_low, (int, float)) and isinstance(flea_high, (int, float)):
|
||||||
|
gap = flea_high - flea_low
|
||||||
|
if gap > 0:
|
||||||
|
rows.append({
|
||||||
|
"id": it["id"],
|
||||||
|
"name": it["name"],
|
||||||
|
"shortName": it["shortName"],
|
||||||
|
"gap": gap,
|
||||||
|
"fleaLow": flea_low,
|
||||||
|
"fleaHigh": flea_high,
|
||||||
|
"avg24h": it.get("avg24hPrice"),
|
||||||
|
"bestTraderBuy": best_trader_buy_rub(it),
|
||||||
|
"bestTraderSell": best_trader_sell_rub(it),
|
||||||
|
"lastOfferCount": it.get("lastOfferCount"),
|
||||||
|
})
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r["gap"], reverse=True)
|
||||||
|
top = rows[:100]
|
||||||
|
|
||||||
|
# Print a quick top list
|
||||||
|
for i, r in enumerate(top, 1):
|
||||||
|
print(f"{i:>2}. {r['name']:<50} gap ₽{r['gap']:,} (low {r['fleaLow']:,} → high {r['fleaHigh']:,})")
|
||||||
|
|
||||||
|
# Save CSV
|
||||||
|
with open("top100_spread.csv", "w", newline="", encoding="utf-8") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=[
|
||||||
|
"id","name","shortName","gap","fleaLow","fleaHigh","avg24h",
|
||||||
|
"bestTraderBuy","bestTraderSell","lastOfferCount"
|
||||||
|
])
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(top)
|
||||||
|
|
||||||
|
print("\nSaved: top100_spread.csv")
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
|
||||||
|
// trader-resets.js (v2.2.0) — Local JSON version with ONE unified "data last updated" bubble
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ===== Config =====
|
||||||
|
const REFRESH_MS = 5 * 60 * 1000; // auto-refresh every 5 minutes
|
||||||
|
const STORAGE_KEY = 'eft.traders.mode'; // 'regular' or 'pve'
|
||||||
|
const DEBUG_TS = false;
|
||||||
|
|
||||||
|
// Local JSON files (served from /var/www/html via cron)
|
||||||
|
const DATA_URL = {
|
||||||
|
regular: '/traders_pvp.json',
|
||||||
|
pve: '/traders_pve.json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== DOM refs =====
|
||||||
|
const els = {
|
||||||
|
grid: document.getElementById('traderGrid'),
|
||||||
|
refresh: document.getElementById('refreshBtn'),
|
||||||
|
modeToggle: document.getElementById('modeToggle'),
|
||||||
|
dataUpdated: document.getElementById('dataUpdated'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!els.grid) {
|
||||||
|
console.warn('[trader-resets] Missing #traderGrid.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Utilities =====
|
||||||
|
// Normalize resetTime (seconds, ms, ISO) -> ms epoch or null
|
||||||
|
function toMsEpoch(resetTime) {
|
||||||
|
if (resetTime == null) return null;
|
||||||
|
if (typeof resetTime === 'number') return resetTime < 1e12 ? resetTime * 1000 : resetTime;
|
||||||
|
if (typeof resetTime === 'string') {
|
||||||
|
const parsed = Date.parse(resetTime);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMode() {
|
||||||
|
return els.modeToggle && els.modeToggle.checked ? 'pve' : 'regular';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHMS(ms) {
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return '00:00:00';
|
||||||
|
const sec = Math.floor(ms / 1000);
|
||||||
|
const h = String(Math.floor(sec / 3600)).padStart(2, '0');
|
||||||
|
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0');
|
||||||
|
const s = String(sec % 60).padStart(2, '0');
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLocalTraders(mode) {
|
||||||
|
const url = DATA_URL[mode] || DATA_URL.regular;
|
||||||
|
const v = Math.floor(Date.now() / 60000); // per-minute cache-bust
|
||||||
|
const res = await fetch(`${url}?v=${v}`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json(); // array of traders
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Last-updated (from static file Last-Modified headers) =====
|
||||||
|
async function fetchLastModified(url) {
|
||||||
|
try {
|
||||||
|
const head = await fetch(url, { method: 'HEAD' });
|
||||||
|
const lm = head.headers.get('Last-Modified');
|
||||||
|
if (lm) return new Date(lm);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const get = await fetch(url + '?stamp=' + Date.now(), { cache: 'no-store' });
|
||||||
|
const lm = get.headers.get('Last-Modified');
|
||||||
|
if (lm) return new Date(lm);
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the *single* bubble to the newest (max) of PvP/PvE file timestamps
|
||||||
|
async function refreshUnifiedUpdatedBubble() {
|
||||||
|
const [pvp, pve] = await Promise.all([
|
||||||
|
fetchLastModified(DATA_URL.regular),
|
||||||
|
fetchLastModified(DATA_URL.pve),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const newest = (!pvp && !pve)
|
||||||
|
? null
|
||||||
|
: (pvp && pve ? (pvp > pve ? pvp : pve) : (pvp || pve));
|
||||||
|
|
||||||
|
if (els.dataUpdated) {
|
||||||
|
els.dataUpdated.textContent =
|
||||||
|
`Data last updated: ${newest ? newest.toLocaleString() : '—'}`;
|
||||||
|
els.dataUpdated.title = newest ? newest.toISOString() : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Rendering =====
|
||||||
|
function renderUnknownCard(trader) {
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'trader-card';
|
||||||
|
card.setAttribute('role', 'listitem');
|
||||||
|
|
||||||
|
const figure = document.createElement('figure');
|
||||||
|
figure.className = 'trader-figure';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'trader-img';
|
||||||
|
img.alt = `${trader.name || 'Trader'}`;
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.src = trader.imageLink || 'https://tarkov.dev/images/traders/default.jpg';
|
||||||
|
figure.appendChild(img);
|
||||||
|
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'trader-name';
|
||||||
|
name.textContent = trader.name || 'Unknown trader';
|
||||||
|
|
||||||
|
const timer = document.createElement('div');
|
||||||
|
timer.className = 'trader-countdown';
|
||||||
|
timer.textContent = 'Unknown';
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'trader-meta';
|
||||||
|
meta.textContent = 'Next reset: —';
|
||||||
|
|
||||||
|
card.appendChild(figure);
|
||||||
|
card.appendChild(name);
|
||||||
|
card.appendChild(timer);
|
||||||
|
card.appendChild(meta);
|
||||||
|
els.grid.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(traders) {
|
||||||
|
els.grid.innerHTML = '';
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
traders.forEach(t => {
|
||||||
|
const nextReset = toMsEpoch(t.resetTime);
|
||||||
|
if (DEBUG_TS) {
|
||||||
|
console.debug('[trader-resets] raw resetTime:', t.name, t.resetTime, '→', nextReset && new Date(nextReset).toISOString());
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(nextReset)) {
|
||||||
|
renderUnknownCard(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msLeft = nextReset - now;
|
||||||
|
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'trader-card';
|
||||||
|
card.setAttribute('role', 'listitem');
|
||||||
|
|
||||||
|
const figure = document.createElement('figure');
|
||||||
|
figure.className = 'trader-figure';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'trader-img';
|
||||||
|
img.alt = t.name || 'Trader';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.src = t.imageLink || 'https://tarkov.dev/images/traders/default.jpg';
|
||||||
|
figure.appendChild(img);
|
||||||
|
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'trader-name';
|
||||||
|
name.textContent = t.name || 'Unknown trader';
|
||||||
|
|
||||||
|
const timer = document.createElement('div');
|
||||||
|
timer.className = 'trader-countdown';
|
||||||
|
timer.textContent = fmtHMS(msLeft);
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'trader-meta';
|
||||||
|
meta.textContent = `Next reset: ${new Date(nextReset).toLocaleTimeString()}`;
|
||||||
|
|
||||||
|
card.appendChild(figure);
|
||||||
|
card.appendChild(name);
|
||||||
|
card.appendChild(timer);
|
||||||
|
card.appendChild(meta);
|
||||||
|
els.grid.appendChild(card);
|
||||||
|
|
||||||
|
// Live ticking
|
||||||
|
let tickId = setInterval(() => {
|
||||||
|
const left = nextReset - Date.now();
|
||||||
|
if (left <= 0) {
|
||||||
|
timer.textContent = '00:00:00';
|
||||||
|
setTimeout(() => { timer.textContent = 'Restocking…'; }, 500);
|
||||||
|
clearInterval(tickId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer.textContent = fmtHMS(left);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Load from local JSON =====
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const mode = currentMode(); // 'regular' or 'pve'
|
||||||
|
const traders = await getLocalTraders(mode);
|
||||||
|
render(traders);
|
||||||
|
|
||||||
|
// Update the unified "data last updated" bubble
|
||||||
|
refreshUnifiedUpdatedBubble().catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[trader-resets] load failed:', err);
|
||||||
|
if (els.dataUpdated) {
|
||||||
|
els.dataUpdated.textContent = 'Data last updated: — (failed to read local JSON)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Persistence: restore & save mode =====
|
||||||
|
if (els.modeToggle) {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved === 'pve') els.modeToggle.checked = true;
|
||||||
|
if (saved === 'regular') els.modeToggle.checked = false;
|
||||||
|
|
||||||
|
els.modeToggle.addEventListener('change', () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, currentMode());
|
||||||
|
load(); // re-render immediately when mode flips
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Events =====
|
||||||
|
els.refresh?.addEventListener('click', load);
|
||||||
|
|
||||||
|
// ===== Kickoff =====
|
||||||
|
load();
|
||||||
|
setInterval(load, REFRESH_MS);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
|
||||||
|
<!-- traders.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>ESCAPE FROM TARKOV COMPANION — Trader Resets</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<!-- Site stylesheet -->
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="index.html" class="logo">ESCAPE FROM TARKOV COMPANION</a>
|
||||||
|
|
||||||
|
<!-- HAMBURGER BUTTON -->
|
||||||
|
<button class="nav-toggle" type="button" aria-controls="primary-nav" aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation">
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle__bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav class="main-nav" id="primary-nav" aria-label="Primary navigation">
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="goons.html">Goons</a></li>
|
||||||
|
<li><a href="maps.html">Maps</a></li>
|
||||||
|
<li><a href="quests.html">Quests</a></li>
|
||||||
|
<li><a href="ammo.html">Ammo</a></li>
|
||||||
|
<li><a href="traders.html">Traders</a></li>
|
||||||
|
<li><a href="pricewatch.html" aria-current="page">Price Watch</a></li>
|
||||||
|
<li><a href="crafts.html">Craft Calculator</a></li>
|
||||||
|
<li><a href="https://tarkovgunsmith.com/ballistics_simulator" target="_blank" rel="noopener">Ballistics Simulator</a></li>
|
||||||
|
<li><a href="https://tarkov-market.com/" target="_blank" rel="noopener">Flea Market</a></li>
|
||||||
|
<li><a href="https://escapefromtarkov.fandom.com/wiki/Escape_from_Tarkov_Wiki" target="_blank"
|
||||||
|
rel="noopener">Wiki</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<section id="Home" aria-labelledby="home-title">
|
||||||
|
<h1 id="home-title" class="page-title" align="center">Trader Inventory Resets</h1>
|
||||||
|
|
||||||
|
<!-- Toolbar: only the controls live here -->
|
||||||
|
<div class="trader-toolbar toolbar-flex">
|
||||||
|
<button id="refreshBtn" type="button" class="btn">Refresh now</button>
|
||||||
|
|
||||||
|
<!-- PvP / PvE toggle -->
|
||||||
|
<label class="mode-toggle" title="Switch between PvP (regular) and PvE data">
|
||||||
|
<input id="modeToggle" type="checkbox" />
|
||||||
|
<span>Use PvE data</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="spacer"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Single unified "data last updated" bubble (from JSON Last-Modified) -->
|
||||||
|
<div class="update-bar">
|
||||||
|
<span id="dataUpdated" class="badge update-pill">Data last updated: —</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trader cards -->
|
||||||
|
<div id="traderGrid" class="trader-grid grid-root" role="list"></div>
|
||||||
|
|
||||||
|
<p class="small-muted">
|
||||||
|
<!--Data is cached locally and refreshed by cron every ~5 minutes.-->
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<p class="error">JavaScript is required to load trader reset times.</p>
|
||||||
|
</noscript>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- App logic -->
|
||||||
|
<script src="trader-resets.js?v=2.2.0"></script>
|
||||||
|
|
||||||
|
<!-- HAMBURGER MENU SCRIPT -->
|
||||||
|
<script>
|
||||||
|
const btn = document.querySelector('.nav-toggle');
|
||||||
|
const nav = document.getElementById('primary-nav');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
||||||
|
btn.setAttribute('aria-expanded', !open);
|
||||||
|
nav.classList.toggle('is-open', !open);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "54cb50c76803fa8b248b4571",
|
||||||
|
"name": "Prapor",
|
||||||
|
"resetTime": "2026-06-25T22:24:58.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/54cb50c76803fa8b248b4571.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "54cb57776803fa99248b456e",
|
||||||
|
"name": "Therapist",
|
||||||
|
"resetTime": "2026-06-25T22:21:07.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/54cb57776803fa99248b456e.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "579dc571d53a0658a154fbec",
|
||||||
|
"name": "Fence",
|
||||||
|
"resetTime": "2026-06-25T21:28:29.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/579dc571d53a0658a154fbec.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "58330581ace78e27b8b10cee",
|
||||||
|
"name": "Skier",
|
||||||
|
"resetTime": "2026-06-25T21:44:20.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/58330581ace78e27b8b10cee.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5935c25fb3acc3127c3d8cd9",
|
||||||
|
"name": "Peacekeeper",
|
||||||
|
"resetTime": "2026-06-25T22:43:25.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/5935c25fb3acc3127c3d8cd9.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5a7c2eca46aef81a7ca2145d",
|
||||||
|
"name": "Mechanic",
|
||||||
|
"resetTime": "2026-06-25T23:44:09.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/5a7c2eca46aef81a7ca2145d.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5ac3b934156ae10c4430e83c",
|
||||||
|
"name": "Ragman",
|
||||||
|
"resetTime": "2026-06-25T22:14:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/5ac3b934156ae10c4430e83c.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5c0647fdd443bc2504c2d371",
|
||||||
|
"name": "Jaeger",
|
||||||
|
"resetTime": "2026-06-25T21:23:53.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/5c0647fdd443bc2504c2d371.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "638f541a29ffd1183d187f57",
|
||||||
|
"name": "Lightkeeper",
|
||||||
|
"resetTime": "2026-06-25T22:00:55.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/638f541a29ffd1183d187f57.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "68fe15910f29ba3fdbba9d54",
|
||||||
|
"name": "Taran",
|
||||||
|
"resetTime": "2026-06-25T21:29:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/68fe15910f29ba3fdbba9d54.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "656f0f98d80a697f855d34b1",
|
||||||
|
"name": "BTR Driver",
|
||||||
|
"resetTime": "2026-06-25T21:09:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/656f0f98d80a697f855d34b1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "68fe15990f29ba3fdbba9d55",
|
||||||
|
"name": "Radio station",
|
||||||
|
"resetTime": "2026-06-25T21:10:55.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/68fe15990f29ba3fdbba9d55.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6617beeaa9cfa777ca915b7c",
|
||||||
|
"name": "Ref",
|
||||||
|
"resetTime": "2026-06-25T22:48:37.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/6617beeaa9cfa777ca915b7c.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "688246518448b05efd61d461",
|
||||||
|
"name": "Mr. Kerman",
|
||||||
|
"resetTime": "2026-06-25T21:51:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/688246518448b05efd61d461.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "688246958448b05efd61d462",
|
||||||
|
"name": "Voevoda",
|
||||||
|
"resetTime": "2026-06-25T22:00:55.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/688246958448b05efd61d462.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "69e0d6cc77b63940375b9173",
|
||||||
|
"name": "Survivor",
|
||||||
|
"resetTime": "2026-06-25T21:34:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/unknown-trader.webp"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "54cb50c76803fa8b248b4571",
|
||||||
|
"name": "Prapor",
|
||||||
|
"resetTime": "2026-06-25T21:20:55.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/54cb50c76803fa8b248b4571.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "54cb57776803fa99248b456e",
|
||||||
|
"name": "Therapist",
|
||||||
|
"resetTime": "2026-06-25T23:28:01.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/54cb57776803fa99248b456e.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "579dc571d53a0658a154fbec",
|
||||||
|
"name": "Fence",
|
||||||
|
"resetTime": "2026-06-25T21:32:00.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/579dc571d53a0658a154fbec.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "58330581ace78e27b8b10cee",
|
||||||
|
"name": "Skier",
|
||||||
|
"resetTime": "2026-06-25T22:43:48.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/58330581ace78e27b8b10cee.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5935c25fb3acc3127c3d8cd9",
|
||||||
|
"name": "Peacekeeper",
|
||||||
|
"resetTime": "2026-06-25T23:20:08.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/5935c25fb3acc3127c3d8cd9.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5a7c2eca46aef81a7ca2145d",
|
||||||
|
"name": "Mechanic",
|
||||||
|
"resetTime": "2026-06-25T23:30:56.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/5a7c2eca46aef81a7ca2145d.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5ac3b934156ae10c4430e83c",
|
||||||
|
"name": "Ragman",
|
||||||
|
"resetTime": "2026-06-25T22:15:58.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/5ac3b934156ae10c4430e83c.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5c0647fdd443bc2504c2d371",
|
||||||
|
"name": "Jaeger",
|
||||||
|
"resetTime": "2026-06-25T22:06:12.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/5c0647fdd443bc2504c2d371.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "638f541a29ffd1183d187f57",
|
||||||
|
"name": "Lightkeeper",
|
||||||
|
"resetTime": "2026-06-25T21:13:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/638f541a29ffd1183d187f57.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "68fe15910f29ba3fdbba9d54",
|
||||||
|
"name": "Taran",
|
||||||
|
"resetTime": "2026-06-25T21:52:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/68fe15910f29ba3fdbba9d54.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "656f0f98d80a697f855d34b1",
|
||||||
|
"name": "BTR Driver",
|
||||||
|
"resetTime": "2026-06-25T21:15:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/656f0f98d80a697f855d34b1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "68fe15990f29ba3fdbba9d55",
|
||||||
|
"name": "Radio station",
|
||||||
|
"resetTime": "2026-06-25T21:16:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/68fe15990f29ba3fdbba9d55.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6617beeaa9cfa777ca915b7c",
|
||||||
|
"name": "Ref",
|
||||||
|
"resetTime": "2026-06-25T23:06:36.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/6617beeaa9cfa777ca915b7c.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "688246518448b05efd61d461",
|
||||||
|
"name": "Mr. Kerman",
|
||||||
|
"resetTime": "2026-06-25T21:38:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/688246518448b05efd61d461.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "688246958448b05efd61d462",
|
||||||
|
"name": "Voevoda",
|
||||||
|
"resetTime": "2026-06-25T22:05:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/688246958448b05efd61d462.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "69e0d6cc77b63940375b9173",
|
||||||
|
"name": "Survivor",
|
||||||
|
"resetTime": "2026-06-25T21:40:54.000Z",
|
||||||
|
"imageLink": "https://assets.tarkov.dev/unknown-trader.webp"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# POSIX-safe (dash/sh compatible)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
OUT="/var/www/EFT_COMPANION/public_html/ammo-data.json"
|
||||||
|
API="https://api.tarkov.dev/graphql"
|
||||||
|
|
||||||
|
# --- Preferred: ammo-only (note lowercase enum value 'ammo') ---
|
||||||
|
GQL_AMMO_ONLY='{
|
||||||
|
items(lang: en, types: [ammo]) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
shortName
|
||||||
|
iconLink
|
||||||
|
wikiLink
|
||||||
|
updated
|
||||||
|
properties {
|
||||||
|
__typename
|
||||||
|
... on ItemPropertiesAmmo {
|
||||||
|
caliber
|
||||||
|
tracer
|
||||||
|
tracerColor
|
||||||
|
damage
|
||||||
|
armorDamage
|
||||||
|
fragmentationChance
|
||||||
|
penetrationPower
|
||||||
|
accuracyModifier
|
||||||
|
recoilModifier
|
||||||
|
initialSpeed
|
||||||
|
lightBleedModifier
|
||||||
|
heavyBleedModifier
|
||||||
|
projectileCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# --- Fallback: all items then filter to ItemPropertiesAmmo ---
|
||||||
|
GQL_ALL_MIN='{
|
||||||
|
items(lang: en) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
shortName
|
||||||
|
iconLink
|
||||||
|
wikiLink
|
||||||
|
updated
|
||||||
|
properties {
|
||||||
|
__typename
|
||||||
|
... on ItemPropertiesAmmo {
|
||||||
|
caliber
|
||||||
|
tracer
|
||||||
|
tracerColor
|
||||||
|
damage
|
||||||
|
armorDamage
|
||||||
|
fragmentationChance
|
||||||
|
penetrationPower
|
||||||
|
accuracyModifier
|
||||||
|
recoilModifier
|
||||||
|
initialSpeed
|
||||||
|
lightBleedModifier
|
||||||
|
heavyBleedModifier
|
||||||
|
projectileCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Build JSON payloads safely via jq (read from stdin, -R = raw, -s = slurp)
|
||||||
|
PAYLOAD_AMMO_ONLY=$(printf '%s' "$GQL_AMMO_ONLY" | jq -Rs '{query: .}')
|
||||||
|
PAYLOAD_ALL_MIN=$(printf '%s' "$GQL_ALL_MIN" | jq -Rs '{query: .}')
|
||||||
|
|
||||||
|
fetch_graphql () {
|
||||||
|
payload="$1"
|
||||||
|
curl -sS -X POST "$API" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
--data-binary "$payload"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try ammo-only first
|
||||||
|
JSON=$(fetch_graphql "$PAYLOAD_AMMO_ONLY")
|
||||||
|
|
||||||
|
# If .data is missing, fall back to broad query
|
||||||
|
if ! printf '%s' "$JSON" | jq -e '.data' >/dev/null 2>&1 ; then
|
||||||
|
echo "Ammo-only query failed, falling back to broad items query…" >&2
|
||||||
|
JSON=$(fetch_graphql "$PAYLOAD_ALL_MIN")
|
||||||
|
if ! printf '%s' "$JSON" | jq -e '.data' >/dev/null 2>&1 ; then
|
||||||
|
echo "GraphQL error on fallback:" >&2
|
||||||
|
printf '%s\n' "$JSON" | jq . >&2 || printf '%s\n' "$JSON" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Normalize to a compact JSON your site can consume
|
||||||
|
TMP="$OUT.tmp"
|
||||||
|
printf '%s' "$JSON" | jq -c '
|
||||||
|
{
|
||||||
|
fetchedAt: (now | todate),
|
||||||
|
source: "api.tarkov.dev/graphql",
|
||||||
|
items: (
|
||||||
|
.data.items
|
||||||
|
| map(select(.properties != null and .properties.__typename == "ItemPropertiesAmmo"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
' > "$TMP"
|
||||||
|
|
||||||
|
mv "$TMP" "$OUT"
|
||||||
|
COUNT=$(jq '.items | length' "$OUT")
|
||||||
|
echo "Wrote $COUNT ammo rows to $OUT at $(date -Is)"
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""
|
||||||
|
update-goons-status.py
|
||||||
|
Fetch "Last Seen" blocks from goon-tracker.com (PvP + PvE) and write a local JSON.
|
||||||
|
|
||||||
|
Pages & markers (as of today):
|
||||||
|
- PvP page: https://www.goon-tracker.com/
|
||||||
|
Shows "Last Seen: <Map>" and a "Time:" line. (wording: "Last Seen:") [ref]
|
||||||
|
- PvE page: https://www.goon-tracker.com/pvetracker
|
||||||
|
Shows "Last Seen on PvE Mode:" then the map + "Time:" + "Last seen:". [ref] [1](https://github.com/the-hideout/tarkov-api/issues/133)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
UA = "EFTC-CompanionBot/1.0 (+yourdomain; admin@yourdomain)"
|
||||||
|
TIMEOUT = 15
|
||||||
|
OUT = "/var/www/EFT_COMPANION/public_html/goons-status.json"
|
||||||
|
|
||||||
|
PVP_URL = "https://www.goon-tracker.com/"
|
||||||
|
PVE_URL = "https://www.goon-tracker.com/pvetracker"
|
||||||
|
|
||||||
|
# Common EFT map names (helps with fallback parsing)
|
||||||
|
KNOWN_MAPS = {
|
||||||
|
"Customs","Shoreline","Lighthouse","Woods","Streets","Reserve",
|
||||||
|
"Interchange","Factory","Labs","Ground","GroundZero","GroundZero","The","TheLab","Lab","TheLab"
|
||||||
|
}
|
||||||
|
|
||||||
|
def fetch_html(url: str) -> str:
|
||||||
|
r = requests.get(url, headers={"User-Agent": UA}, timeout=TIMEOUT)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.text
|
||||||
|
|
||||||
|
def text_nodes(soup):
|
||||||
|
for el in soup.find_all(string=True):
|
||||||
|
txt = (el or "").strip()
|
||||||
|
if txt:
|
||||||
|
yield txt
|
||||||
|
|
||||||
|
def clean_token(s: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", s).strip()
|
||||||
|
|
||||||
|
def parse_time_and_ago(all_text):
|
||||||
|
time_text, last_seen_text = "", ""
|
||||||
|
for t in all_text:
|
||||||
|
if t.startswith("Time:"):
|
||||||
|
time_text = t.split("Time:", 1)[-1].strip()
|
||||||
|
elif t.startswith("Last seen:"):
|
||||||
|
last_seen_text = t.split("Last seen:", 1)[-1].strip()
|
||||||
|
return time_text, last_seen_text
|
||||||
|
|
||||||
|
def parse_pvp(html: str):
|
||||||
|
"""
|
||||||
|
Strategy (PvP):
|
||||||
|
1) Find a text line that starts with "Last Seen".
|
||||||
|
2) Try to capture the map on the same line or the next nearby text node(s).
|
||||||
|
3) Also collect "Time:" and "Last seen:" if present.
|
||||||
|
The PvP homepage wording is "Last Seen: <Map>" (without 'PvE'). [ref]
|
||||||
|
"""
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
all_text = list(text_nodes(soup))
|
||||||
|
last_seen_map = None
|
||||||
|
time_text, last_seen_text = "", ""
|
||||||
|
|
||||||
|
# 1) Direct regex on any line containing "Last Seen"
|
||||||
|
for idx, t in enumerate(all_text):
|
||||||
|
if t.lower().startswith("last seen"):
|
||||||
|
# Try "Last Seen: Lighthouse" pattern
|
||||||
|
m = re.search(r"Last\s*Seen\s*:?\s*([A-Za-z][A-Za-z ]{2,30})", t, flags=re.I)
|
||||||
|
if m:
|
||||||
|
candidate = clean_token(m.group(1))
|
||||||
|
# If the candidate includes extra words, trim to first known map token
|
||||||
|
for tok in candidate.replace("-", " ").split():
|
||||||
|
if tok in KNOWN_MAPS:
|
||||||
|
last_seen_map = tok
|
||||||
|
break
|
||||||
|
if not last_seen_map:
|
||||||
|
# As a fallback, keep first word (often it's already just 'Customs', etc.)
|
||||||
|
last_seen_map = candidate.split()[0]
|
||||||
|
# 2) If not found on same line, scan a small window ahead
|
||||||
|
if not last_seen_map:
|
||||||
|
window = " ".join(all_text[idx: idx+6])
|
||||||
|
m2 = re.search(r"\b(Customs|Shoreline|Lighthouse|Woods|Streets|Reserve|Interchange|Factory|Labs)\b", window, flags=re.I)
|
||||||
|
if m2:
|
||||||
|
last_seen_map = clean_token(m2.group(1))
|
||||||
|
|
||||||
|
break # Stop after first marker
|
||||||
|
|
||||||
|
# Times and "last seen:" (ago)
|
||||||
|
time_text, last_seen_text = parse_time_and_ago(all_text)
|
||||||
|
|
||||||
|
debug = ""
|
||||||
|
if not last_seen_map:
|
||||||
|
# Provide a small debug snippet for troubleshooting
|
||||||
|
sample = [t for t in all_text if t.lower().startswith("last seen")][:2]
|
||||||
|
debug = f"pvp_debug: marker_lines={sample}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"map": last_seen_map,
|
||||||
|
"timeText": time_text,
|
||||||
|
"lastSeenText": last_seen_text,
|
||||||
|
"source": PVP_URL,
|
||||||
|
"debug": debug
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_pve(html: str):
|
||||||
|
"""
|
||||||
|
Strategy (PvE):
|
||||||
|
- Find the text "Last Seen on PvE Mode:" then read the nearby span/inline with the map token.
|
||||||
|
- Also capture "Time:" and "Last seen:" lines if present.
|
||||||
|
The PvE page explicitly uses the "Last Seen on PvE Mode" header. [ref] [1](https://github.com/the-hideout/tarkov-api/issues/133)
|
||||||
|
"""
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
all_text = list(text_nodes(soup))
|
||||||
|
last_seen_map = None
|
||||||
|
|
||||||
|
marker = soup.find(string=lambda s: isinstance(s, str) and "Last Seen on PvE Mode" in s)
|
||||||
|
if marker:
|
||||||
|
# Try closest siblings/children for a short map token
|
||||||
|
siblings_text = []
|
||||||
|
for sib in marker.parent.next_siblings:
|
||||||
|
if hasattr(sib, "get_text"):
|
||||||
|
txt = sib.get_text(strip=True)
|
||||||
|
else:
|
||||||
|
txt = str(sib).strip()
|
||||||
|
if txt:
|
||||||
|
siblings_text.append(txt)
|
||||||
|
if len(siblings_text) > 5:
|
||||||
|
break
|
||||||
|
|
||||||
|
# First sibling chunk is often the map (e.g., "Lighthouse")
|
||||||
|
if siblings_text:
|
||||||
|
# If contains multiple words, try to pick a known map token
|
||||||
|
words = siblings_text[0].replace("-", " ").split()
|
||||||
|
for w in words:
|
||||||
|
if w in KNOWN_MAPS:
|
||||||
|
last_seen_map = w
|
||||||
|
break
|
||||||
|
if not last_seen_map:
|
||||||
|
last_seen_map = words[0]
|
||||||
|
|
||||||
|
time_text, last_seen_text = parse_time_and_ago(all_text)
|
||||||
|
|
||||||
|
debug = ""
|
||||||
|
if not last_seen_map:
|
||||||
|
sample = [t for t in all_text if "Last Seen on PvE Mode" in t][:2]
|
||||||
|
debug = f"pve_debug: marker_lines={sample}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"map": last_seen_map,
|
||||||
|
"timeText": time_text,
|
||||||
|
"lastSeenText": last_seen_text,
|
||||||
|
"source": PVE_URL,
|
||||||
|
"debug": debug
|
||||||
|
}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pvp_html = fetch_html(PVP_URL)
|
||||||
|
pve_html = fetch_html(PVE_URL)
|
||||||
|
|
||||||
|
pvp = parse_pvp(pvp_html)
|
||||||
|
pve = parse_pve(pve_html)
|
||||||
|
|
||||||
|
now_iso = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||||
|
data = {
|
||||||
|
"fetchedAt": now_iso,
|
||||||
|
"pvp": pvp,
|
||||||
|
"pve": pve
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp = OUT + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
os.replace(tmp, OUT)
|
||||||
|
print(f"Wrote {OUT}: PvP={pvp.get('map')} PvE={pve.get('map')} at {now_iso}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# update-quests.sh — fetch EFT quests and write atomically to quests.json
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
umask 022
|
||||||
|
|
||||||
|
# ---- Environment safe for cron ----
|
||||||
|
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
|
||||||
|
OUT="/var/www/EFT_COMPANION/public_html/quests.json"
|
||||||
|
OUT_DIR="$(dirname "$OUT")"
|
||||||
|
ENDPOINT="https://api.tarkov.dev/graphql"
|
||||||
|
|
||||||
|
# ---- Helpers ----
|
||||||
|
die() { echo "[update-quests] ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Confirm required tools exist (cron PATH can be minimal)
|
||||||
|
for bin in curl mktemp mv chmod head date; do
|
||||||
|
command -v "$bin" >/dev/null 2>&1 || die "Required binary '$bin' not found in PATH"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
# GraphQL query — single line to avoid escaping double quotes
|
||||||
|
# (Removed fields that caused schema errors: Skill.normalizedName, craftUnlock.name)
|
||||||
|
QUERY='query AllTasks { tasks { id name trader { id name imageLink } wikiLink objectives { id type description maps { normalizedName } ... on TaskObjectiveItem { count foundInRaid item { id name shortName iconLink } items { id name shortName iconLink } } ... on TaskObjectiveShoot { targetNames count } } finishRewards { items { count item { id name shortName iconLink } } traderStanding { standing trader { id name } } offerUnlock { item { id name } trader { id name } } skillLevelReward { level skill { id name } } traderUnlock { id name } craftUnlock { id } achievement { id name } customization { id name } } } }'
|
||||||
|
|
||||||
|
# Minimal JSON payload (no double quotes inside QUERY)
|
||||||
|
PAYLOAD=$(printf '{"query":"%s"}' "$QUERY")
|
||||||
|
|
||||||
|
# Create temp file in the same directory, then atomically mv into place
|
||||||
|
TMP="$(mktemp -p "$OUT_DIR" 'quests.json.tmp.XXXXXX')" || die "mktemp failed"
|
||||||
|
# Clean up the temp file on any exit path (cleared later after mv)
|
||||||
|
cleanup() { [ -n "${TMP:-}" ] && [ -f "$TMP" ] && rm -f "$TMP" || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[update-quests] Posting to $ENDPOINT ..."
|
||||||
|
HTTP_STATUS="$(
|
||||||
|
curl -sS -o "$TMP" -w '%{http_code}' \
|
||||||
|
-X POST "$ENDPOINT" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary "$PAYLOAD"
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ "$HTTP_STATUS" != "200" ]; then
|
||||||
|
echo "[update-quests] ERROR: HTTP $HTTP_STATUS"
|
||||||
|
echo "[update-quests] Response body (first 2000 bytes):"
|
||||||
|
head -c 2000 "$TMP" || true; echo
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure readable by web server
|
||||||
|
chmod 0644 "$TMP"
|
||||||
|
|
||||||
|
# Atomic replace
|
||||||
|
mv -f "$TMP" "$OUT"
|
||||||
|
# Prevent trap from trying to remove a moved file
|
||||||
|
TMP=""
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
echo "[update-quests] Wrote $OUT at $(date -Is)"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Directory where JSON will be saved
|
||||||
|
OUTDIR="/var/www/EFT_COMPANION/public_html/"
|
||||||
|
|
||||||
|
# Tarkov.dev endpoint
|
||||||
|
URL="https://api.tarkov.dev/graphql"
|
||||||
|
|
||||||
|
# PvP query
|
||||||
|
read -r -d '' Q_PVP << 'EOF'
|
||||||
|
{
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
shortName
|
||||||
|
avg24hPrice
|
||||||
|
lastLowPrice
|
||||||
|
low24hPrice
|
||||||
|
high24hPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# PvE query
|
||||||
|
read -r -d '' Q_PVE << 'EOF'
|
||||||
|
{
|
||||||
|
items(gameMode: pve) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
shortName
|
||||||
|
avg24hPrice
|
||||||
|
lastLowPrice
|
||||||
|
low24hPrice
|
||||||
|
high24hPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Pull PvP data
|
||||||
|
curl -s -X POST -H "Content-Type: application/json" \
|
||||||
|
--data "{\"query\":\"${Q_PVP//$'\n'/ }\"}" \
|
||||||
|
"$URL" | jq '.data.items' > "$OUTDIR/items_pvp.json"
|
||||||
|
|
||||||
|
# Pull PvE data
|
||||||
|
curl -s -X POST -H "Content-Type: application/json" \
|
||||||
|
--data "{\"query\":\"${Q_PVE//$'\n'/ }\"}" \
|
||||||
|
"$URL" | jq '.data.items' > "$OUTDIR/items_pve.json"
|
||||||
|
|
||||||
|
# Optional combined file
|
||||||
|
jq -n \
|
||||||
|
--slurpfile pvp "$OUTDIR/items_pvp.json" \
|
||||||
|
--slurpfile pve "$OUTDIR/items_pve.json" \
|
||||||
|
'{updatedAt: now, pvp: $pvp[0], pve: $pve[0]}' \
|
||||||
|
> "$OUTDIR/tarkov_all.json"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pull trader reset times (PvP & PvE) from Tarkov.dev and save as local JSON.
|
||||||
|
# Writes: /var/www/EFT_COMPANION/public_html/traders_pvp.json, /var/www/EFT_COMPANION/public_html/traders_pve.json
|
||||||
|
# Logs: /var/log/eft_traders_pull.log
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
umask 022
|
||||||
|
|
||||||
|
OUTDIR="/var/www/EFT_COMPANION/public_html/"
|
||||||
|
URL="https://api.tarkov.dev/graphql"
|
||||||
|
LOG="/var/log/eft_traders_pull.log"
|
||||||
|
|
||||||
|
# Ensure log file exists and is writable (no sudo; assume root or correct perms)
|
||||||
|
mkdir -p /var/log
|
||||||
|
touch "$LOG"
|
||||||
|
|
||||||
|
log() { echo "[$(date -Is)] $*" | tee -a "$LOG"; }
|
||||||
|
|
||||||
|
# Compact, single-line GraphQL bodies (avoids quoting/heredoc pitfalls)
|
||||||
|
Q_PVP='{"query":"{ traders(gameMode: regular) { id name resetTime imageLink } }"}'
|
||||||
|
Q_PVE='{"query":"{ traders(gameMode: pve) { id name resetTime imageLink } }"}'
|
||||||
|
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
|
||||||
|
tmp_pvp="$(mktemp)"
|
||||||
|
tmp_pve="$(mktemp)"
|
||||||
|
raw_pvp="$(mktemp)"
|
||||||
|
raw_pve="$(mktemp)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$tmp_pvp" "$tmp_pve" "$raw_pvp" "$raw_pve"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
log "Starting trader pulls…"
|
||||||
|
|
||||||
|
# ---- PvP ----
|
||||||
|
if curl -fSs -X POST "$URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "$Q_PVP" -o "$raw_pvp"
|
||||||
|
then
|
||||||
|
if jq -e '.errors' "$raw_pvp" >/dev/null 2>&1; then
|
||||||
|
log "ERROR: PvP GraphQL returned errors:"
|
||||||
|
jq '.errors' "$raw_pvp" | tee -a "$LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
jq '.data.traders' "$raw_pvp" > "$tmp_pvp"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
log "ERROR: curl PvP request failed (exit $rc). Raw (if any):"
|
||||||
|
cat "$raw_pvp" | tee -a "$LOG" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- PvE ----
|
||||||
|
if curl -fSs -X POST "$URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "$Q_PVE" -o "$raw_pve"
|
||||||
|
then
|
||||||
|
if jq -e '.errors' "$raw_pve" >/dev/null 2>&1; then
|
||||||
|
log "ERROR: PvE GraphQL returned errors:"
|
||||||
|
jq '.errors' "$raw_pve" | tee -a "$LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
jq '.data.traders' "$raw_pve" > "$tmp_pve"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
log "ERROR: curl PvE request failed (exit $rc). Raw (if any):"
|
||||||
|
cat "$raw_pve" | tee -a "$LOG" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Atomic move into place
|
||||||
|
mv "$tmp_pvp" "$OUTDIR/traders_pvp.json"
|
||||||
|
mv "$tmp_pve" "$OUTDIR/traders_pve.json"
|
||||||
|
chmod 644 "$OUTDIR/traders_pvp.json" "$OUTDIR/traders_pve.json"
|
||||||
|
|
||||||
|
log "Success: wrote $OUTDIR/traders_pvp.json and $OUTDIR/traders_pve.json"
|
||||||