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"
|
||||