pubg map: built-in intel layers (vehicle spots, drops, loot density, Blue Chip towers, secret rooms) per map
All checks were successful
Deploy production / deploy (push) Successful in 1m12s

Read-only layers from static/pubg-intel/<map>.v1.json (scripts/build-pubg-intel.py):
positions from pubg.gs (game level files), drops from guides, secret rooms and Deston
keys as approximate rings on the named place. Layer panel (K), per-player choices in
localStorage, drawn below room markers; clicks still place markers.
This commit is contained in:
2026-10-26 00:05:46 +00:00
parent b1edf7761c
commit be3473caa0
15 changed files with 1057 additions and 5 deletions

View File

@@ -20,7 +20,22 @@
import Footprints from 'lucide-svelte/icons/footprints';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import X from 'lucide-svelte/icons/x';
import Layers from 'lucide-svelte/icons/layers';
import { PIN_SVG, esc, initials } from './pins';
import {
DROP_COLOR,
DROP_LABEL,
INTEL_GROUPS,
INTEL_KEY,
INTEL_LAYERS,
INTEL_SVG,
VEHICLE_ICON,
loadIntel,
readLayerPrefs,
type IntelDoc,
type IntelLayerId,
type IntelPoint
} from './intel';
import {
KIND_INFO,
KINDS,
@@ -75,6 +90,8 @@
timeZone: 'Europe/Amsterdam'
});
const ME_KEY = 'pubg-map:me';
const INTEL_CREDIT =
'Map intel: <a href="https://pubg.gs/" target="_blank" rel="noreferrer">pubg.gs</a> and drop guides (sources under Layers)';
// Connection and room state (the markers themselves live in `layers`, outside Svelte).
let conn = $state<'connecting' | 'live' | 'down' | 'gone'>('connecting');
@@ -95,6 +112,13 @@
let showGrid = $state(true);
let showPast = $state(false);
let drawer = $state(false);
let layersOpen = $state(false);
// Zoomed out: intel icons shrink to dots so the map stays readable.
let far = $state(true);
// Built-in intel layers: per player (localStorage), never sent to the room.
let layerOn = $state(readLayerPrefs(null));
// undefined while loading, null for a map without intel (Camp Jackal).
let intel = $state.raw<IntelDoc | null | undefined>(undefined);
let picker = $state(false);
let fullscreen = $state(false);
let toast = $state<string | null>(null);
@@ -124,6 +148,9 @@
let gridLayer: Leaflet.LayerGroup | undefined;
let pastLayer: Leaflet.LayerGroup | undefined;
let tempLayer: Leaflet.LayerGroup | undefined;
let intelLayer: Leaflet.LayerGroup | undefined;
let attrib: Leaflet.Control.Attribution | undefined;
let creditShown = false;
let ws: WebSocket | null = null;
let disposed = false;
let retry = 0;
@@ -463,6 +490,113 @@
}
}
function intelTip(p: IntelPoint, kind: string) {
const src = intel?.sources[p.s];
const parts = [`<b>${esc(p.n)}</b>`];
if (kind) parts.push(` <span class="pm-dim">${esc(kind)}</span>`);
if (p.d) parts.push(`<br>${esc(p.d)}`);
if (src)
parts.push(
`<br><span class="pm-dim">Source: ${esc(src.name)}${p.a ? ' · approximate spot' : ''}</span>`
);
return parts.join('');
}
function intelIcon(svg: string, color: string, cls = '', opacity = 1) {
return L.divIcon({
className: 'pm-icon',
html: `<div class="pm-ii ${cls}" style="--c:${color};opacity:${opacity}">${svg}</div>`,
iconSize: [18, 18],
iconAnchor: [9, 9]
});
}
// Few and important: these keep their full icon when zoomed out.
const KEEP_ICON = new Set<IntelLayerId>(['special', 'drops', 'secret', 'keys', 'bears']);
function drawIntel() {
if (!intelLayer || !L) return;
intelLayer.clearLayers();
if (!intel) return;
const tip = { className: 'pm-tip', direction: 'top', offset: [0, -8] } as const;
for (const def of INTEL_LAYERS) {
const pts = intel.layers[def.id];
if (!layerOn[def.id] || !pts?.length) continue;
for (const p of pts) {
if (def.id === 'loot') {
const r = p.r ?? 0.01;
L.rectangle([ll(p.x - r, p.y - r), ll(p.x + r, p.y + r)], {
pane: 'pm-area',
color: def.color,
weight: 0.5,
opacity: 0.6,
fillColor: def.color,
fillOpacity: 0.12 + 0.4 * (p.w ?? 0.5)
})
.bindTooltip(intelTip(p, 'loot density'), { className: 'pm-tip', sticky: true })
.addTo(intelLayer);
continue;
}
if (p.a && p.r)
L.circle(ll(p.x, p.y), {
pane: 'pm-area',
radius: p.r * S,
color: def.color,
weight: 1.5,
dashArray: '4 5',
fillColor: def.color,
fillOpacity: 0.06,
interactive: false
}).addTo(intelLayer);
let svg = INTEL_SVG[def.icon as keyof typeof INTEL_SVG];
let color = def.color;
let kind = '';
let opacity = 1;
if (def.group === 'Vehicles') {
svg = INTEL_SVG[VEHICLE_ICON[p.t ?? ''] ?? 'car'];
// Fainter = less likely to hold a vehicle this match.
if (p.p !== undefined) opacity = 0.45 + 0.55 * Math.min(1, p.p * 1.5);
if (p.p !== undefined) kind = `~${Math.round(p.p * 100)}% per match`;
} else if (def.id === 'drops') {
color = DROP_COLOR[p.t ?? ''] ?? def.color;
kind = DROP_LABEL[p.t ?? ''] ?? '';
}
L.marker(ll(p.x, p.y), {
pane: 'pm-intel',
icon: intelIcon(
svg,
color,
[p.a && 'approx', def.id === 'towns' && 'town', KEEP_ICON.has(def.id) && 'keep']
.filter(Boolean)
.join(' '),
opacity
),
keyboard: false,
bubblingMouseEvents: true
})
.bindTooltip(intelTip(p, kind), tip)
.addTo(intelLayer);
}
}
}
// The intel credit shows while the current map has intel.
function syncCredit() {
if (!attrib || !!intel === creditShown) return;
creditShown = !!intel;
if (creditShown) attrib.addAttribution(INTEL_CREDIT);
else attrib.removeAttribution(INTEL_CREDIT);
}
function setLayer(id: IntelLayerId, on: boolean) {
layerOn[id] = on;
try {
localStorage.setItem(INTEL_KEY, JSON.stringify(layerOn));
} catch {
// private mode: the choice lasts this tab only
}
}
let tilesSlug = '';
function setMap(slug: string, fit: boolean) {
if (!map) return;
@@ -640,11 +774,17 @@
tempLayer?.clearLayers();
note = null;
picker = false;
layersOpen = false;
confirmClear = false;
} else if (k === 'g') showGrid = !showGrid;
else if (k === 'o') showPast = !showPast;
else if (k === 't') drawer = !drawer;
else if (k === 'm') picker = !picker;
else if (k === 't') {
drawer = !drawer;
if (drawer) layersOpen = false;
} else if (k === 'k') {
layersOpen = !layersOpen;
if (layersOpen) drawer = false;
} else if (k === 'm') picker = !picker;
else if (k === 'f') toggleFullscreen();
else if (k === ' ' && hover) {
e.preventDefault();
@@ -657,6 +797,23 @@
void current;
drawGrid();
});
// Intel for the current map: fetched once per map, then redrawn when layers toggle.
$effect(() => {
const slug = current.slug;
intel = undefined;
loadIntel(slug).then((d) => {
if (current.slug === slug) intel = d;
});
});
$effect(() => {
void intel;
for (const l of INTEL_LAYERS) void layerOn[l.id];
drawIntel();
});
$effect(() => {
void intel;
syncCredit();
});
$effect(() => {
void showPast;
void focusMatch;
@@ -681,6 +838,11 @@
// no saved nickname
}
if (!me) openName();
try {
layerOn = readLayerPrefs(localStorage.getItem(INTEL_KEY));
} catch {
// storage blocked: defaults
}
import('leaflet').then((mod) => {
if (disposed) return;
L = (mod as unknown as { default: typeof Leaflet }).default ?? mod;
@@ -697,7 +859,14 @@
maxBoundsViscosity: 0.8,
attributionControl: false
});
L.control.attribution({ prefix: false, position: 'bottomleft' }).addTo(map);
attrib = L.control.attribution({ prefix: false, position: 'bottomleft' }).addTo(map);
creditShown = false;
syncCredit();
// Intel sits under everything players place: areas above the tiles, points under
// past landings (overlayPane 400) and room markers (markerPane 600).
map.createPane('pm-area').style.zIndex = '330';
map.createPane('pm-intel').style.zIndex = '380';
intelLayer = L.layerGroup().addTo(map);
L.control.zoom({ position: 'bottomright' }).addTo(map);
gridLayer = L.layerGroup().addTo(map);
pastLayer = L.layerGroup().addTo(map);
@@ -710,7 +879,12 @@
map.on('mousemove', (e: Leaflet.LeafletMouseEvent) => (hover = xy(e.latlng)));
map.on('mouseout', () => (hover = null));
map.on('movestart', () => (note = null));
// Dots below ~1.5 zoom steps past "whole map fits"; full icons once zoomed in.
const farBelow = map.getZoom() + 1.5;
far = true;
map.on('zoomend', () => (far = (map?.getZoom() ?? 0) < farBelow));
drawGrid();
drawIntel();
connect();
});
const onFs = () => {
@@ -726,6 +900,8 @@
map?.remove();
map = undefined;
tiles = undefined;
intelLayer = undefined;
attrib = undefined;
tilesSlug = '';
};
}
@@ -733,7 +909,7 @@
<svelte:window onkeydown={onKey} />
<div class={['pm', tool && 'picking']}>
<div class={['pm', tool && 'picking', far && 'far']}>
<div class="pm-map" {@attach attachMap}></div>
<!-- Top bar: room, match, map, connection; players and actions on the right. -->
@@ -779,9 +955,20 @@
onclick={() => (showPast = !showPast)}
title="Past landings on this map (O)"><Footprints size={16} /></button
>
<button
class={['btn', layersOpen && 'on']}
onclick={() => {
layersOpen = !layersOpen;
if (layersOpen) drawer = false;
}}
title="Map layers: vehicles, drops, loot, secrets (K)"><Layers size={16} /></button
>
<button
class={['btn', drawer && 'on']}
onclick={() => (drawer = !drawer)}
onclick={() => {
drawer = !drawer;
if (drawer) layersOpen = false;
}}
title="Match history (T)"><History size={16} /></button
>
<button class="btn" onclick={toggleFullscreen} title="Fullscreen (F)">
@@ -909,6 +1096,90 @@
</div>
{/if}
{#if layersOpen}
<aside class="drawer layers" aria-label="Map layers">
<div class="picker-head">
<span>{current.name} layers</span><button
class="btn"
onclick={() => (layersOpen = false)}
aria-label="Close"><X size={15} /></button
>
</div>
<p class="small top">Only on your screen; your friends pick their own.</p>
{#each INTEL_GROUPS as group (group)}
{@const defs = INTEL_LAYERS.filter((l) => l.group === group)}
<section>
<h3>{group}</h3>
{#each defs as def (def.id)}
{@const n = intel?.layers[def.id]?.length ?? 0}
<label class={['layer', !n && 'none']} title={def.hint}>
<input
type="checkbox"
checked={layerOn[def.id]}
disabled={!n}
onchange={(e) => {
setLayer(def.id, e.currentTarget.checked);
// Hand the keyboard shortcuts back to the map.
e.currentTarget.blur();
}}
/>
<span class="ic" style:--c={def.color}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- static Lucide icon nodes from intel.ts -->
{@html INTEL_SVG[def.icon as keyof typeof INTEL_SVG]}
</span>
<span class="name">{def.label}</span>
<span class="n">{intel ? n || 'none' : intel === null ? '' : '…'}</span>
</label>
{/each}
</section>
{/each}
{#if layerOn.drops && intel?.layers.drops?.length}
<p class="legend">
{#each Object.entries(DROP_COLOR) as [tier, c] (tier)}
<span style:--c={c}>{DROP_LABEL[tier]}</span>
{/each}
</p>
{/if}
{#if intel?.notes.length}
<section>
<h3>Notes</h3>
{#each intel.notes as nt, i (i)}
<p class="note-item">
<b>{nt.title}.</b>
{nt.text}
{#if intel.sources[nt.s]}<a
href={intel.sources[nt.s].url}
target="_blank"
rel="noreferrer">{intel.sources[nt.s].name}</a
>{/if}
</p>
{/each}
</section>
{/if}
<p class="small">
Dashed rings are approximate: drawn on the named place, not the exact spot. Faint vehicle
icons rarely hold a vehicle.
</p>
{#if intel}
<section>
<h3>Sources</h3>
<ul class="sources">
{#each Object.entries(intel.sources) as [k, src] (k)}
<li>
<a href={src.url} target="_blank" rel="noreferrer">{src.name}</a>{#if src.note}
<span class="pm-dim"> · {src.note}</span>{/if}
</li>
{/each}
</ul>
</section>
{:else if intel === null}
<p class="small">No built-in intel for this map.</p>
{:else}
<p class="small">Loading map intel…</p>
{/if}
</aside>
{/if}
{#if drawer}
<aside class="drawer" aria-label="Match history">
<div class="picker-head">
@@ -1328,6 +1599,74 @@
width: 300px;
overflow: auto;
}
.layers h3 {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin: 10px 0 4px;
}
.layers .top {
margin-top: 0;
}
.layer {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 4px;
border-radius: 7px;
cursor: pointer;
}
.layer:hover {
background: rgb(255 255 255 / 0.06);
}
.layer.none {
opacity: 0.45;
cursor: default;
}
.layer input {
accent-color: #fafafa;
}
.layer .ic {
color: var(--c);
display: grid;
}
.layer .name {
flex: 1;
}
.layer .n {
color: var(--muted);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.legend span {
font-size: 11px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--c);
color: var(--c);
}
.note-item {
font-size: 12px;
line-height: 1.45;
margin-bottom: 6px;
}
.layers a {
color: var(--text);
text-decoration: underline;
text-decoration-color: rgb(255 255 255 / 0.35);
}
.layers .sources {
gap: 3px;
font-size: 11.5px;
}
.drawer ul {
display: flex;
flex-direction: column;
@@ -1518,6 +1857,31 @@
.pm :global(.pm-tip::before) {
display: none;
}
.pm :global(.pm-ii) {
width: 100%;
height: 100%;
display: grid;
place-items: center;
border-radius: 50%;
background: rgb(10 10 10 / 0.78);
color: var(--c);
border: 1px solid color-mix(in oklab, var(--c) 65%, transparent);
}
.pm :global(.pm-ii.town) {
border-radius: 5px;
}
.pm.far :global(.pm-ii:not(.keep)) {
transform: scale(0.42);
background: var(--c);
border: 2px solid rgb(0 0 0 / 0.6);
}
.pm.far :global(.pm-ii:not(.keep) svg) {
display: none;
}
.pm :global(.pm-ii.approx) {
border-style: dashed;
border-width: 1.5px;
}
.pm :global(.pm-dim) {
color: var(--muted);
}

View File

@@ -0,0 +1,40 @@
import { readFileSync, readdirSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { INTEL_LAYERS, INTEL_MAPS, readLayerPrefs, type IntelDoc } from './intel';
import { MAPS } from './shared';
describe('pubg map intel', () => {
it('defaults to places and vehicles, and keeps saved choices', () => {
const d = readLayerPrefs(null);
expect(d.towns && d.cars && d.boats).toBe(true);
expect(d.drops || d.loot || d.secret).toBe(false);
const saved = readLayerPrefs(JSON.stringify({ cars: false, drops: true, bogus: true }));
expect(saved.cars).toBe(false);
expect(saved.drops).toBe(true);
expect(readLayerPrefs('{not json')).toEqual(d);
});
it('ships sourced, in-bounds intel for every map', () => {
const dir = 'static/pubg-intel';
const files = readdirSync(dir);
const ids = new Set<string>(INTEL_LAYERS.map((l) => l.id));
expect(files.sort()).toEqual([...INTEL_MAPS].map((s) => `${s}.v1.json`).sort());
const slugs = new Set(MAPS.map((m) => m.slug));
for (const f of files) {
const doc: IntelDoc = JSON.parse(readFileSync(`${dir}/${f}`, 'utf8'));
expect(f).toBe(`${doc.map}.v1.json`);
expect(slugs.has(doc.map)).toBe(true);
for (const [id, pts] of Object.entries(doc.layers)) {
expect(ids.has(id)).toBe(true);
for (const p of pts ?? []) {
expect(p.x).toBeGreaterThan(0);
expect(p.x).toBeLessThan(1);
expect(p.y).toBeGreaterThan(0);
expect(p.y).toBeLessThan(1);
expect(doc.sources[p.s]?.url).toMatch(/^https:\/\//);
}
}
for (const n of doc.notes) expect(doc.sources[n.s]).toBeTruthy();
}
});
});

296
src/lib/pubg-map/intel.ts Normal file
View File

@@ -0,0 +1,296 @@
// Built-in, read-only intel layers for the PUBG drop map: towns, vehicle spots, drops,
// loot and secret rooms per map. Static JSON (static/pubg-intel/<slug>.v1.json, built by
// scripts/build-pubg-intel.py); nothing here is stored per room or sent over the socket.
export const INTEL_VERSION = 1;
export const INTEL_KEY = 'pubg-map:layers';
export type IntelPoint = {
x: number;
y: number;
/** Name shown first in the tooltip. */
n: string;
/** Note. */
d?: string;
/** Sub-type (vehicle type, drop tier). */
t?: string;
/** Source id (key into IntelDoc.sources). */
s: string;
/** 1 = approximate: drawn on the named place, not the exact spot. */
a?: 1;
/** Radius in map units (0..1), for rings and loot cells. */
r?: number;
/** Weight 0..1 (loot cells) or a count (towns). */
w?: number;
/** Spawn chance 0..1 (vehicle spots). */
p?: number;
};
export type IntelSource = { name: string; url: string; note?: string };
export type IntelDoc = {
v: number;
map: string;
built: string;
sources: Record<string, IntelSource>;
notes: { title: string; text: string; s: string }[];
layers: Partial<Record<IntelLayerId, IntelPoint[]>>;
};
export const INTEL_GROUPS = ['Places', 'Vehicles', 'Drops', 'Loot', 'Secrets'] as const;
export type IntelGroup = (typeof INTEL_GROUPS)[number];
export type IntelLayerId =
| 'towns'
| 'bridges'
| 'cars'
| 'garages'
| 'boats'
| 'gliders'
| 'special'
| 'drops'
| 'loot'
| 'towers'
| 'vending'
| 'bears'
| 'secret'
| 'keys';
export type IntelLayer = {
id: IntelLayerId;
label: string;
group: IntelGroup;
color: string;
/** On for a player who never touched the layer panel. */
on: boolean;
icon: string;
hint: string;
};
// Lucide icon nodes (ISC licence, copied from lucide-svelte) plus a hand-drawn bridge,
// inlined because Leaflet divIcons are plain HTML.
const svg = (body: string, size = 12) =>
`<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
const P = {
town: '<path d="M10 12h4"/><path d="M10 8h4"/><path d="M14 21v-3a2 2 0 0 0-4 0v3"/><path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2"/><path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/>',
bridge:
'<path d="M2 12h20"/><path d="M4 12V8"/><path d="M20 12V8"/><path d="M4 8q8 6 16 0"/><path d="M8 12v4"/><path d="M16 12v4"/>',
car: '<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/>',
garage:
'<path d="M18 21V10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v11"/><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 1.132-1.803l7.95-3.974a2 2 0 0 1 1.837 0l7.948 3.974A2 2 0 0 1 22 8z"/><path d="M6 13h12"/><path d="M6 17h12"/>',
boat: '<path d="M12 10.189V14"/><path d="M12 2v3"/><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6"/><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76"/><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/>',
glider:
'<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>',
crown:
'<path d="M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/><path d="M5 21h14"/>',
flame:
'<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4"/>',
loot: '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/>',
tower:
'<path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/>',
vending:
'<path d="M5 6a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6Z"/><path d="M5 10h14"/><path d="M15 7v6"/>',
bear: '<circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
key: '<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>'
};
export const INTEL_SVG = Object.fromEntries(
Object.entries(P).map(([k, v]) => [k, svg(v)])
) as Record<keyof typeof P, string>;
export const INTEL_LAYERS: IntelLayer[] = [
{
id: 'towns',
label: 'Named places',
group: 'Places',
color: '#e4e4e7',
on: true,
icon: 'town',
hint: 'towns and landmarks, with how many item spots are inside'
},
{
id: 'bridges',
label: 'Bridges',
group: 'Places',
color: '#94a3b8',
on: false,
icon: 'bridge',
hint: 'bridge points from the game files'
},
{
id: 'cars',
label: 'Car spots',
group: 'Vehicles',
color: '#c084fc',
on: true,
icon: 'car',
hint: 'road and off-road spots; the chance is per spot per match'
},
{
id: 'garages',
label: 'Garages',
group: 'Vehicles',
color: '#a855f7',
on: true,
icon: 'garage',
hint: 'garage and police-station spots (high chance)'
},
{
id: 'boats',
label: 'Boats',
group: 'Vehicles',
color: '#38bdf8',
on: true,
icon: 'boat',
hint: 'boat, Aquarail and Airboat spots'
},
{
id: 'gliders',
label: 'Motor gliders',
group: 'Vehicles',
color: '#f0f9ff',
on: true,
icon: 'glider',
hint: 'motor glider spots'
},
{
id: 'special',
label: 'Special vehicles',
group: 'Vehicles',
color: '#facc15',
on: true,
icon: 'crown',
hint: 'fixed rare spawns such as the Gold Mirado'
},
{
id: 'drops',
label: 'Hot and quiet drops',
group: 'Drops',
color: '#f97316',
on: false,
icon: 'flame',
hint: 'from drop guides: red hot, amber balanced, green quiet'
},
{
id: 'loot',
label: 'Loot density',
group: 'Loot',
color: '#4ade80',
on: false,
icon: 'loot',
hint: 'the squares with the most item spots (game files, aggregated)'
},
{
id: 'towers',
label: 'Blue Chip towers',
group: 'Loot',
color: '#60a5fa',
on: false,
icon: 'tower',
hint: 'Blue Chip transmitter spots'
},
{
id: 'vending',
label: 'Vending machines',
group: 'Loot',
color: '#f472b6',
on: false,
icon: 'vending',
hint: 'vending machine spots'
},
{
id: 'bears',
label: 'Bear caves',
group: 'Loot',
color: '#f8fafc',
on: false,
icon: 'bear',
hint: 'polar bear spawns (bear caves, airdrop-tier loot)'
},
{
id: 'secret',
label: 'Secret rooms',
group: 'Secrets',
color: '#fb923c',
on: false,
icon: 'lock',
hint: 'approximate: drawn around the named place'
},
{
id: 'keys',
label: 'Security keys',
group: 'Secrets',
color: '#fde047',
on: false,
icon: 'key',
hint: 'approximate: drawn around the named place'
}
];
export const INTEL_BY_ID = Object.fromEntries(INTEL_LAYERS.map((l) => [l.id, l])) as Record<
IntelLayerId,
IntelLayer
>;
export const VEHICLE_ICON: Record<string, keyof typeof P> = {
car: 'car',
garage: 'garage',
boat: 'boat',
glider: 'glider',
special: 'crown'
};
export const DROP_COLOR: Record<string, string> = {
hot: '#ef4444',
busy: '#f97316',
balanced: '#facc15',
quiet: '#4ade80'
};
export const DROP_LABEL: Record<string, string> = {
hot: 'Hot drop',
busy: 'Busy drop',
balanced: 'Balanced drop',
quiet: 'Quiet drop'
};
/** The player's on/off choices merged over the defaults. */
export function readLayerPrefs(raw: string | null): Record<IntelLayerId, boolean> {
const out = Object.fromEntries(INTEL_LAYERS.map((l) => [l.id, l.on])) as Record<
IntelLayerId,
boolean
>;
try {
const saved = JSON.parse(raw ?? 'null');
if (saved && typeof saved === 'object')
for (const l of INTEL_LAYERS) if (typeof saved[l.id] === 'boolean') out[l.id] = saved[l.id];
} catch {
// keep the defaults
}
return out;
}
/** Maps with a static/pubg-intel file (Camp Jackal, the training map, has none). */
export const INTEL_MAPS = new Set([
'erangel',
'miramar',
'sanhok',
'vikendi',
'taego',
'deston',
'rondo',
'karakin',
'paramo',
'haven'
]);
const docs = new Map<string, Promise<IntelDoc | null>>();
/** Fetches a map's intel once per page load; null when the map has none. */
export function loadIntel(slug: string): Promise<IntelDoc | null> {
if (!INTEL_MAPS.has(slug)) return Promise.resolve(null);
let p = docs.get(slug);
if (!p) {
p = fetch(`/pubg-intel/${slug}.v${INTEL_VERSION}.json`)
.then((r) => (r.ok ? (r.json() as Promise<IntelDoc>) : null))
.catch(() => null);
p.then((d) => d || docs.delete(slug));
docs.set(slug, p);
}
return p;
}