// DiveDex — UI primitives + bottom nav + top bar
function Logo({ size = 28, mark = true, word = true, gold = false, name = 'DiveDex' }) {
const accent = gold ? '#D7B46A' : '#82ECFF';
const accent2 = gold ? '#FBE6A6' : '#31D7FF';
return (
{mark && (
)}
{word && (
DiveDex
)}
);
}
function SonarRing({ size = 220, pulses = 3 }) {
return (
{Array.from({ length: pulses }).map((_, i) => (
))}
);
}
function GlassCard({ children, style = {}, strong = false, onClick, accent }) {
return (
{children}
);
}
function Chip({ children, variant = 'default', icon, style }) {
const cls = {
default: 'dd-chip', aqua: 'dd-chip dd-chip-aqua', gold: 'dd-chip dd-chip-gold',
coral: 'dd-chip dd-chip-coral', foam: 'dd-chip dd-chip-foam',
}[variant];
return {icon}{children} ;
}
function XPBar({ value, max, color = 'var(--aqua)', height = 8, label, sub, animate = true }) {
const pct = Math.min(100, (value / max) * 100);
return (
{(label || sub) && (
{label}
{sub}
)}
);
}
function RarityRing({ rarity, size = 86, children }) {
const isMythic = rarity === 'mythic';
return (
);
}
// Bottom navigation — 5 tabs
function BottomNav({ tab, onTab }) {
const items = [
{ id: 'levels', label: 'Levels', icon: },
{ id: 'log', label: 'Log', icon: },
{ id: 'dex', label: 'Dex', icon: },
{ id: 'team', label: 'Team', icon: },
{ id: 'memories', label: 'Memories', icon: },
{ id: 'gear', label: 'Gear', icon: },
];
return (
{items.map(it => {
const active = tab === it.id;
return (
onTab(it.id)} className="dd-btn" style={{
background: 'transparent', padding: '8px 4px 6px', borderRadius: 22,
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
color: active ? '#82ECFF' : 'var(--ink-3)',
position: 'relative',
}}>
{active &&
}
{it.icon}
{it.label}
);
})}
);
}
function NavIcon({ kind }) {
const s = { width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' };
if (kind === 'levels') return ;
if (kind === 'log') return ;
if (kind === 'dex') return ;
if (kind === 'team') return ;
if (kind === 'memories') return ;
if (kind === 'gear') return ;
}
function TopBar({ title, sub, left, right, scroll = 0 }) {
// Always-on blurred backdrop so scrolling content doesn't bleed visibly
// through the sticky header. The `scroll` prop is no longer required —
// kept for back-compat with any caller still passing it.
//
// The TopBar owns the device safe-area-inset-top: its background extends
// all the way to the top of the viewport (under the iOS Dynamic Island /
// notch), and its internal top padding is `14 + safe-area-inset-top` so
// the title sits *below* the status bar. Sticky `top: 0` then keeps it
// pinned to the very top of the scrollport, with no double-padding from
// the scroll container. In a browser tab or on desktop the env value
// resolves to 0 so this collapses to a normal 14px top padding.
return (
);
}
function IconBtn({ children, onClick }) {
return (
{children}
);
}
function ProgressDots({ step, total }) {
return (
{Array.from({ length: total }).map((_, i) => (
))}
);
}
function RarityFrame({ rarity, children, style }) {
return (
);
}
function StatCell({ label, value, unit, color = '#EAF5FF', mono = true }) {
return (
{label}
{value}
{unit && {unit} }
);
}
function Avatar({ name, color, size = 28 }) {
return (
{name[0]}
);
}
function PhotoBlock({ ratio = '4 / 3', label = 'photo', style = {}, tint = 'rgba(49,215,255,0.06)' }) {
return (
{label}
);
}
// DiveSiteMap — stylized regional chart with dive-site pins.
//
// Props:
// region: region key (e.g. 'japan') — picks the bounding box + label
// sites: [{ name, lat, lng, highlight?: bool }] — markers to render
// height: pixel height of the rendered map (default 200)
// onSiteTap: optional click handler invoked with the tapped site name
//
// The silhouette is hand-drawn for vibe (it's not survey-accurate); the
// markers project real lat/lng into the same coordinate space so the
// relative positions of dive sites are correct.
function DiveSiteMap({ region, sites, height = 200, onSiteTap }) {
const bounds = (window.DD_STATE && window.DD_STATE.REGION_MAP_BOUNDS && window.DD_STATE.REGION_MAP_BOUNDS[region]) || null;
if (!bounds || !sites || sites.length === 0) return null;
const VBW = 200, VBH = 250;
const project = (lat, lng) => {
const x = ((lng - bounds.minLng) / (bounds.maxLng - bounds.minLng)) * VBW;
const y = VBH - ((lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * VBH;
return [x, y];
};
// Clamp markers to viewBox with a small inset so they never sit on the edge.
const inset = 10;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const projected = sites.map(s => {
const [x, y] = project(s.lat, s.lng);
return { ...s, x: clamp(x, inset, VBW - inset), y: clamp(y, inset, VBH - inset) };
});
// Coastline silhouettes — one per region. They're decorative; if a region
// doesn't have one yet, we fall back to "no land", which still looks fine
// because the markers carry the meaning.
const LAND_PATHS = {
'japan':
'M8,8 L192,8 L192,55 Q180,75 170,90 Q162,110 158,140 Q150,175 130,200 ' +
'Q100,225 65,220 Q38,215 30,190 Q22,160 28,140 Q35,125 22,110 ' +
'Q12,98 18,80 Q25,65 22,45 Q20,28 8,18 Z',
};
const land = LAND_PATHS[region];
return (
{/* Faint navigational grid */}
{/* Land silhouette */}
{land && (
)}
{/* Compass rose */}
N
{/* Markers */}
{projected.map((s, i) => {
const c = s.highlight ? '#D7B46A' : '#82ECFF';
const glow = s.highlight ? 'url(#dd-map-pin-hot)' : 'url(#dd-map-pin-glow)';
return (
onSiteTap(s.name) : undefined}>
{s.highlight && (
)}
);
})}
{/* Label strip */}
{bounds.label}
{sites.length} {sites.length === 1 ? 'site' : 'sites'}
);
}
// Resolve a site name -> { name, lat, lng } using SEED_DIVE_SITE_GEO, or null.
function resolveDiveSiteGeo(name) {
if (!name) return null;
const geo = (window.DD_STATE && window.DD_STATE.SEED_DIVE_SITE_GEO) || {};
const hit = geo[name];
if (!hit) return null;
return { name, lat: hit.lat, lng: hit.lng, region: hit.region };
}
Object.assign(window, {
Logo, SonarRing, GlassCard, Chip, XPBar, RarityRing, BottomNav, NavIcon,
TopBar, IconBtn, ProgressDots, RarityFrame, StatCell, Avatar, PhotoBlock,
DiveSiteMap, resolveDiveSiteGeo,
});